Compare commits
No files matched your search
+191
-1
@@ -18,6 +18,196 @@
|
||||
"bin/dev-router.php"
|
||||
],
|
||||
"port": 8080
|
||||
},
|
||||
{
|
||||
"name": "kantsites-pilot",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-a1c96276da5c/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=24M -d post_max_size=26M -d memory_limit=256M -S 127.0.0.1:8092 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8092
|
||||
},
|
||||
{
|
||||
"name": "elektro-bernhard",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/elektro-bernhard.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/elektro-bernhard-media'; $env:SITE_ORIGIN='http://127.0.0.1:8088'; & php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8088 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8088
|
||||
},
|
||||
{
|
||||
"name": "tagespflege-moldovan",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/tagespflege-moldovan.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/tagespflege-moldovan-media'; $env:SITE_ORIGIN='http://127.0.0.1:8089'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8089 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8089
|
||||
},
|
||||
{
|
||||
"name": "kantit",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantit.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; $env:SITE_ORIGIN='http://127.0.0.1:8130'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8130 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8130
|
||||
},
|
||||
{
|
||||
"name": "maler-drews",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/maler-drews.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/maler-drews-media'; $env:SITE_ORIGIN='http://127.0.0.1:8093'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8093 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8093
|
||||
},
|
||||
{
|
||||
"name": "immobilien-deeg",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/immobilien-deeg.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/immobilien-deeg-media'; $env:SITE_ORIGIN='http://127.0.0.1:8094'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8094 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8094
|
||||
},
|
||||
{
|
||||
"name": "ebsolar",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebsolar.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebsolar-media'; $env:SITE_ORIGIN='http://127.0.0.1:8096'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8096 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8096
|
||||
},
|
||||
{
|
||||
"name": "ebsolar-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebsolar.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebsolar-media'; $env:SITE_ORIGIN='http://127.0.0.1:8097'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8097 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8097
|
||||
},
|
||||
{
|
||||
"name": "immobilien-deeg-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/immobilien-deeg.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/immobilien-deeg-media'; $env:SITE_ORIGIN='http://127.0.0.1:8095'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8095 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8095
|
||||
},
|
||||
{
|
||||
"name": "kantarci-immobilien",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8098'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8098 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8098
|
||||
},
|
||||
{
|
||||
"name": "kantarci-immobilien-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8099'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8099 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8099
|
||||
},
|
||||
{
|
||||
"name": "kantarci-markt",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-markt.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-markt-media'; $env:SITE_ORIGIN='http://127.0.0.1:8100'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8100 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8100
|
||||
},
|
||||
{
|
||||
"name": "kantarci-markt-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-markt.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-markt-media'; $env:SITE_ORIGIN='http://127.0.0.1:8101'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8101 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8101
|
||||
},
|
||||
{
|
||||
"name": "huefner",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/huefner.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8102'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8102 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8102
|
||||
},
|
||||
{
|
||||
"name": "huefner-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/huefner.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8103'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8103 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8103
|
||||
},
|
||||
{
|
||||
"name": "kantarci-reisen",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-reisen.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-reisen-media'; $env:SITE_ORIGIN='http://127.0.0.1:8104'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8104 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8104
|
||||
},
|
||||
{
|
||||
"name": "kantarci-reisen-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-reisen.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-reisen-media'; $env:SITE_ORIGIN='http://127.0.0.1:8105'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8105 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8105
|
||||
},
|
||||
{
|
||||
"name": "sprachen-test",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/lang/huefner-lang.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8111'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8111 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8111
|
||||
},
|
||||
{
|
||||
"name": "sprachen-test-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/lang/huefner-lang.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8112'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8112 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8112
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ APP_DEBUG=0
|
||||
DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"
|
||||
# Für MySQL stattdessen, mit den Zugangsdaten des Hostings:
|
||||
# DATABASE_URL="mysql://benutzer:passwort@127.0.0.1:3306/datenbank?charset=utf8mb4"
|
||||
# Die oeffentliche Adresse der Website, mit oder ohne www - so, wie sie in Sitemap, Feed und
|
||||
# Canonical stehen soll. Die andere Schreibweise leitet die Anwendung dorthin um.
|
||||
SITE_ORIGIN=https://example.de
|
||||
MEDIA_DIRECTORY="%kernel.project_dir%/var/media"
|
||||
MAILER_DSN=sendmail://default
|
||||
|
||||
@@ -83,3 +83,8 @@ jobs:
|
||||
- run: php vendor/bin/doctrine-migrations migrate --no-interaction
|
||||
- run: php bin/console cms:seed-demo
|
||||
- run: php bin/console cms:seed-demo
|
||||
# Vorher hoerte der Job hier auf und sagte nichts ueber Sicherung und Betrieb auf MySQL.
|
||||
- run: php bin/console cms:health
|
||||
- run: php bin/console cms:backup --keep=2
|
||||
- run: php bin/console cms:backup --list
|
||||
- run: php vendor/bin/phpunit
|
||||
@@ -1,12 +1,112 @@
|
||||
# KantSites
|
||||
|
||||
Lokaler CMS-Prototyp für PHP-Webhosting mit erstem visuellen Editor. Symfony 7.4, Twig, Doctrine DBAL/Migrations; SQLite lokal, MySQL/MariaDB als Hostingziel. Drei gespeicherte Beispielseiten werden ohne JavaScript als HTML ausgeliefert. Anmeldung, geschützte Bearbeitung und getrennte Veröffentlichung sind implementiert; weitere Layoutwerkzeuge folgen.
|
||||
Selbst betreibbares Block-CMS für PHP-Webhosting mit visuellem Editor nach dem Bedienprinzip des Flatsome UX Builders. Symfony 7.4, Twig, Doctrine DBAL/Migrations; SQLite oder MySQL/MariaDB. Öffentliche Seiten kommen als fertiges HTML ohne Pflicht-JavaScript, mit Sitemap, Feed, strukturierten Daten und `llms.txt`. Webinstaller, Releasearchive mit Update aus der Verwaltung, Sicherung, Aufgabenplan, Formulare, Statistik und Einwilligung sind eingebaut. Fünf Websites laufen lokal als eigene Installationen (Kant-IT, Elektro Bernhard, Tagespflege Moldovan, Malerservice Drews, DEEG Immobilien), `web.kant-it.de` und das Drews-Hosting mit Releasearchiven.
|
||||
|
||||
Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. Den tatsächlichen Stand enthält `docs/STATUS.md`, Entscheidungen und Datenmodell stehen in `docs/ARCHITECTURE.md`.
|
||||
|
||||
## Buttons im Menü (ab 1.26.0)
|
||||
|
||||
Unter **Menüs** einen Eintrag aufklappen und **Als Button darstellen** anhaken. Dann stehen Form, Darstellung und Größe (leer = Website-Standard unter Design → Buttons), Fläche und Schrift – auch beim Überfahren – sowie die Symbolfarbe mit der Farbpalette zur Wahl; das Symbol wird wie im Editor im Fenster gewählt, dazu Bewegung und „Nur das Symbol zeigen“. **Auf dem Telefon**: im aufgeklappten Menü (volle Breite), neben dem Menü-Knopf oder dort nur als Symbol. Eine Vorschau zeigt den Button normal und beim Überfahren. Einträge mit Unterpunkten können kein Button sein. Prüfung: `npm run test:menu-buttons`, Screenshots unter `reports/menu-buttons/`. Details: ADR 110.
|
||||
|
||||
## Bildecken unter Design (ab 1.21.0)
|
||||
|
||||
Wie stark die Ecken der Bilder gerundet sind, steht jetzt unter **Design → Bildecken** und gilt für die ganze Website – Bilder, Galerien, Logoleisten und Beitragslisten. Vorgabe ist `12px` wie bisher, `0px` macht sie eckig. Ein einzelnes Bild lässt sich davon abweichend einstellen: im Editor beim ausgewählten Bild unter **Design → Bild → Bildecken**, dort auch je Gerät und je Ecke. Vorher stand die Rundung fest im Stylesheet und war in keinem Feld zu finden.
|
||||
|
||||
## Kaufmännisches Und und Zeilenumbrüche bleiben, wie sie sind (ab 1.20.1)
|
||||
|
||||
**Behoben.** Ein Text ohne Auszeichnung wurde beim Speichern doppelt kodiert: aus `&` wurde `&`, beim nächsten Speichern `&amp;` – irgendwann stand die Entity sichtbar auf der Seite. Ebenso wuchsen Zeilenumbrüche: jedes zweite Speichern machte aus einem `<br>` zwei. Beides ist behoben, und wiederholtes Speichern ändert einen Text jetzt nicht mehr. Was bereits falsch gespeichert ist, räumt `php bin/console cms:repair:entities` auf: ohne Option zeigt er nur, was sich ändern würde, mit `--schreiben` wendet er es an. Angefasst werden die veröffentlichte Fassung und der Entwurf jeder Seite sowie die Bereiche; ältere Fassungen bleiben Historie.
|
||||
|
||||
## Eigenes HTML und eingebettete Seiten (ab 1.20.0)
|
||||
|
||||
Der Baustein **Eigenes HTML** (Werkzeugkasten, Gruppe „Seite und Daten“) nimmt Markup auf, das unverändert auf der Seite landet – eigene Tabellen, fremde Widgets, Auszeichnungen, die es als Baustein nicht gibt. Ändern darf ihn nur ein **Administrator**, genau wie das Feld „Eigenes Skript“; für alle anderen ist er sichtbar und schreibgeschützt, und der Server weist eine Änderung ohnehin ab. Zwei Tags nimmt er nicht an: `<script>` gehört unter „Erweitert“ in „Eigenes Skript“, und ein `<iframe>` in den Baustein **Externer Inhalt** – nur der fragt vorher um Einwilligung und trägt den Anbieter in die Sicherheitsrichtlinie ein (ADR 106).
|
||||
|
||||
Am eingebetteten Rahmen lässt sich alles gestalten, was dieser Website gehört: Höhe als Eigenschaft, Abstände, Breite, Rand und Ecken über Design-Tokens und freie Stile, dazu eigenes CSS am Block (`& iframe{…}`) und für den Platzhalter vor der Einwilligung (`&.embed-blocked`, `& .embed-title`, `& .consent-button`). **Was im Rahmen steht, gehört dem Anbieter** – daran kommt kein CSS dieser Website heran, das trennt der Browser zwischen zwei Herkünften. Den Einwilligungsbalken selbst gestaltet man unter **Einstellungen** (Form und vier Farben).
|
||||
|
||||
## Adresse mit oder ohne www, Rollen wie versprochen, Gesamtprüfung (ab 1.17.0)
|
||||
|
||||
**Eine Adresse.** `SITE_ORIGIN` in `.env` entscheidet, ob die Website mit oder ohne `www` heißt; die andere Schreibweise leitet die Anwendung selbst mit 301 dorthin um (auch die Verwaltung), ein fremder Host bleibt erreichbar. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt die Einstellung, der Bildschirm „System“ zeigt sie und warnt, wenn die Verwaltung gerade unter einer anderen Adresse läuft. Der www-Block in `public/.htaccess` ist weg.
|
||||
|
||||
**Rollen.** Redakteure bearbeiten Entwürfe, laden Medien hoch und sehen die Statistik. Veröffentlicher dürfen zusätzlich veröffentlichen, Bereiche, Menüs, Formulare, Weiterleitungen und die 404-Seite pflegen. Administratoren allein ändern Design, Unternehmensdaten (samt Einwilligung und Spamschutz), Benutzer, System und eigene Skripte. Vorher genügte für Design, Einstellungen, Menüs, Bereiche und Formulare die Rolle Redakteur – entgegen der Rollenhilfe. Die Navigation zeigt nur, was die Rolle öffnen darf.
|
||||
|
||||
**Behoben.** Nach `cms:admin:path` war keine Anmeldung möglich (Sitzungscookie blieb auf `/admin`). Der Installer öffnete sich wieder, sobald die Datenbank nicht antwortete. Ein MySQL-Passwort mit Sonderzeichen ließ den Container abbrechen (`resolve:`), ebenso ein SMTP-Passwort. Die Einwilligung nahm Entscheidungen von fremden Websites an und leitete auf `/\fremd.example` weiter. `</script>` in Titel oder FAQ zerriss die strukturierten Daten. Eigenes CSS konnte mit einem Komma den Block verlassen. Eine alte Fassung konnte ein Skript an der Administratorregel vorbei zurückholen. Im Editor ließen sich Menü, Beitragsliste und Slider in älteren Dokumenten nicht speichern (Schemafassung), „Gestaltung einfügen“ und „Als Vorlage speichern“ waren fälschlich aktiv, „Ausrücken“ und Baum-Drop umgingen die Elternregeln, ein Bild auf ein Akkordeon warf einen Fehler, abgelehnte Einfügungen hinterließen leere Verlaufsschritte. Dazu: Schatten bei Überfahren/Scrollen, Tabs und Bildbetrachter in Kopf, Fuß und Sektionen, Sitemap-`lastmod`, Feed-Zeitzone, Vollbild in eingebetteten Videos, Update-Downloads nur über HTTPS-Weiterleitungen, Composer- und PHP-Aufruf aus dem Web-Update unter php-fpm, unerwartete Fehler im Updater nehmen den Rückweg, eine geplante Veröffentlichung blockiert die anderen nicht mehr, keine Unterseite unter einer Seite im Papierkorb.
|
||||
|
||||
## Spaltenbreiten einer Zeile durch Ziehen (ab 1.16.0)
|
||||
|
||||
Wähle eine Zeile oder eine ihrer Spalten: auf jeder Grenze zwischen zwei Spalten liegt ein Griff. Ziehen macht die eine Spalte breiter und die andere schmaler, Loslassen ist ein Schritt für „Rückgängig“; die Pfeiltasten verschieben um fünf Prozentpunkte. Der Wert steht auch im Design-Bedienfeld der Zeile unter **Layout → Spaltenbreiten**, etwa `2 1` oder `25 50 25`, und gilt je Gerät. Wo die Spalten gestapelt sind, etwa auf dem Handy, gibt es keinen Griff und die Spalten bleiben untereinander.
|
||||
|
||||
## Rückstände der Gesamtprüfung (unveröffentlicht)
|
||||
|
||||
Archivseiten (`/beitraege`, Kategorien, Schlagwörter) sind jetzt gewöhnliche Seiten mit Kopf, Fuß, Sektionen, eigenem CSS und Nach-oben-Knopf; blätternde Beitragslisten tragen Canonical mit Seitenzahl und `rel=prev/next`. Formulare, Beitragslisten und Brotkrumen funktionieren auch in Kopf, Fuß und verknüpften Sektionen, eigene Skripte von Bereichen laufen. Eine zweimal verknüpfte Sektion bekommt beim zweiten Mal eigene Kennungen. Tablet und Telefon trennen sich überall bei 767 px. Dunkle Paletten färben Formularfelder, Fehlermeldungen, Skip-Link und Aufruf-Buttons mit; es gibt Druckstile. **Betrieb:** Sicherung, Wiederherstellung, Archiv-Update und Rückweg laufen auch auf MySQL/MariaDB (`mysqldump` und `mysql` müssen aufrufbar sein), Updates sperren sich gegenseitig (`var/update.lock`), das Archiv wird direkt in eine Datei geladen, `public/.htaccess` behält beim Archiv-Update eingeschaltete HTTPS- und HSTS-Zeilen, und nach dem Update werden `var/update/` (bis auf zwei) und alte Caches aufgeräumt. Der Editor zeigt in der Vorschau eigenes CSS, Bereichsstile und Formulare; frisch eingefügte Kontaktdaten, Formulare, verknüpfte Sektionen und externe Inhalte sind gültig, bis man sie füllt.
|
||||
|
||||
## Plugins (unveröffentlicht)
|
||||
|
||||
Erweiterungen liegen als Ordner unter `plugins/` mit einer `plugin.json`, Twig-Vorlagen und optional Stylesheet und Skript unter `assets/`. Unter **Plugins** (Administrator) werden sie ein- und ausgeschaltet; ihre Bausteine erscheinen dann im Editor unter „Erweiterungen“, mit einem Eigenschaftenpanel aus dem Schema des Manifests. Der Kern prüft die Eigenschaften beim Speichern und beim Rendern, liefert Stylesheet und Skript nur auf Seiten mit dem Baustein aus und lässt die Bausteine eines ausgeschalteten Plugins unangetastet in den Seiten stehen. Ein Plugin kann außerdem Container-Bausteine (mit Liste erlaubter Kinder), Einstellungen für den Betreiber (Formular auf dem Bildschirm „Plugins“), eigene Adressen wie `/vergleich/daten` (Twig-Vorlage, Antwort als html, json, text oder xml), und Seitenvorlagen für den Editor mitbringen. Farben, Schriften und Buttons fasst ein Plugin nicht an – die ändert man unter **Design** (ADR 105). PHP-Code bringt ein Plugin nicht mit – deshalb gibt es keine Hooks und keine eigenen Tabellen (ADR 103). Mitgeliefert ist **Bildvergleich** (`plugins/vergleich`: Bildvergleich, Vergleichsgalerie, Vorlage, Adresse, Einstellungen). Wie man ein Plugin schreibt, steht in `docs/PLUGINS.md`. Migrationen `20260915000100` und `20260916000100` legen die Tabelle `plugin` mit Einstellungen an.
|
||||
|
||||
## Formulare in Schritten, mit Bedingungen und Zusammenfassung (unveröffentlicht)
|
||||
|
||||
Unter **Formulare** gibt es drei neue Feldtypen – **Zahl** (mit Unter-, Obergrenze und Einheit wie m²), **Auswahl (Optionsfelder)** und **Mehrfachauswahl** – und den Typ **Neuer Schritt**: seine Beschriftung ist der Titel des Abschnitts, sein Hilfetext die Einleitung, alle Felder bis zum nächsten Schritt gehören dazu. Jedes Feld und jeder Schritt kann von einer früheren Auswahl abhängen („Nur zeigen, wenn das Feld `immobilienart` einen dieser Werte hat: Wohnung, Haus“); der Server verlangt und speichert es dann nur, wenn die Bedingung zutrifft. Bei Optionsfeldern und Mehrfachauswahl lässt sich je Option ein **Bild aus der Mediathek** wählen; sobald eine Option eines hat, zeigt die Website die Auswahl als anklickbare Bildkarten statt als Kästchen. Unter Zustellung schaltet „Zusammenfassung vor dem Absenden zeigen“ einen letzten Schritt mit allen Angaben und „Ändern“ je Schritt ein. Auf der Website zeigt ein kleines Skript einen Schritt nach dem anderen mit Fortschritt, „Zurück“ und „Weiter“; ohne JavaScript steht das Formular als Ganzes mit Abschnitten auf der Seite. Das Skript kommt nur auf Seiten, deren Formular Schritte, Bedingungen oder die Zusammenfassung hat. Beispiel: der Preisfinder der DEEG-Website (`scripts/seed-immobilien-deeg.php`, Formular `bewertung`).
|
||||
|
||||
## Eigene Absenderadresse je Formular (ab 1.15.1)
|
||||
|
||||
Unter **Formulare → Zustellung** gibt es neben dem Empfänger ein Feld **Absender**. Bisher ging jede Nachricht von der Empfängeradresse an sich selbst; das scheitert, sobald der Empfänger ein Postfach bei einem anderen Anbieter ist, weil viele Hoster nur zustellen, was von einer Adresse der eigenen Domain kommt. Leer heißt weiterhin: Absender ist der Empfänger. Die Antwortadresse aus dem Formularfeld bleibt davon unberührt.
|
||||
|
||||
## Formulare, Statistik, Design und Seitenleiste überarbeitet (ab 1.15.0)
|
||||
|
||||
**Formulare** sind ein Editor: jedes Feld eine Karte mit Nummer, Beschriftung, Typ, technischem Namen, Hilfetext und Pflicht-Schalter, dazu „Feld hinzufügen“ und ein Entfernen-Knopf je Karte. Der technische Name folgt der Beschriftung, bis du ihn selbst schreibst. Unter **Aussehen** bekommt jedes Formular eine Buttonbeschriftung sowie Rahmen- und Hintergrundfarbe der Felder, Button- und Buttonschriftfarbe – aus der Palette oder als eigener Wert, leer heißt „wie die Website“. Die Vorschau daneben folgt jeder Eingabe, zeichnet den Absendeknopf mit dem Standardbutton des Designs und nennt den Kontrast der Buttonschrift.
|
||||
|
||||
**Die Statistik** beginnt mit Kennzahlen: Aufrufe mit Vergleich zum gleich langen Zeitraum davor, Schnitt je Tag, stärkster Tag, aufgerufene Seiten, Aufrufe über Verweise, nicht gefundene Adressen. Der Verlauf hat Rasterlinien, jeder Balken nennt beim Überfahren Tag und Zahl, der stärkste Tag ist hervorgehoben; neu ist ein Wochentagsdiagramm im Schnitt. Die Ranglisten tragen Rang und Anteilsbalken.
|
||||
|
||||
**Im Design** ist die Palette eine Reihe von Farbkarten, deren Fläche der Farbwähler selbst ist. Sieben fertige Paletten (Wald, Ozean, Sand, Koralle, Schiefer, Olive, Nacht) lassen sich per Klick übernehmen; eigene Farben bleiben stehen. Text-, Seiten- und Linkfarbe wählst du aus der Palette oder trägst einen eigenen Wert ein. Jede Schrift zeigt eine Probe in sich selbst, die Schnitte sind Chips, und eine Typografie-Vorschau zeigt Schrift, Größe, Zeilenhöhe, Skala, Farben und Unterstreichung live.
|
||||
|
||||
**Die Seitenleiste** endet in einer Kontokarte mit Avatar, Name, Rolle und Abmeldesymbol, darunter die Fassung mit dem Update-Hinweis. Auf Tablets bleiben Konto und Abmelden rechts im Bild, nur die Liste wischt.
|
||||
|
||||
## Fußzeile des Einwilligungshinweises (ab 1.14.2)
|
||||
|
||||
„Einzeln wählen“ steht links, Datenschutz und Impressum rechts auf einer Linie; aufgeklappt nimmt die Einzelwahl die volle Breite, die Links rutschen rechtsbündig darunter.
|
||||
|
||||
## Einwilligungshinweis gestalten, Nach-oben-Knopf auch bei offener Frage (ab 1.14.1)
|
||||
|
||||
**Der Einwilligungshinweis lässt sich gestalten**, unter **Einstellungen → Einwilligung → Aussehen des Hinweises**: Anordnung (Karte unten in der Mitte, Leiste über die volle Breite, kleine Karte rechts unten), Form (abgerundet, rund, eckig – gilt für Fläche und Knöpfe zusammen), eigene Überschrift und vier Farben (Fläche, Schrift, Knopf „Alle akzeptieren“, Schrift auf dem Knopf), jeweils als `#hex` oder `@schlüssel` aus der Palette. Leer heißt Papier, Tinte und Akzent aus dem Design. Die Fläche trägt einen schmalen Streifen und ein Symbol in der Knopffarbe; „Nur notwendige“ bleibt ein gleich großer Rahmenknopf daneben. Dieselben Farben gelten auf `/einwilligung` und in den Beitragsarchiven.
|
||||
|
||||
**Der Nach-oben-Knopf** war ausgeblendet, solange die Einwilligungsfrage offen stand – auf dem Telefon bleibt sie das oft, und der Knopf kam dort nie. Jetzt setzt er sich auf die Oberkante der Leiste, wo sie die Ecke abdeckt; Browser ohne Ankerpositionierung blenden ihn dort weiter aus. Der reservierte Abstand unten reicht auf schmalen Bildschirmen wieder für die ganze Leiste.
|
||||
|
||||
**Im Designbereich** sind belegte Eigenschaften jetzt in Orange beschriftet, nicht nur mit Punkt und Fettdruck; die Gerätechips D/T/M stehen weiter auseinander, und der Fokusring bleibt in der Lücke.
|
||||
|
||||
## Schriften vom eigenen Server, Hinweise auf jedem Bildschirm (ab 1.14.0)
|
||||
|
||||
**Schriften kommen ausschließlich von deinem Server.** Die Ausgabe verlinkt kein Stylesheet mehr von `fonts.googleapis.com` – weder die öffentliche Seite noch die Editorvorschau, die es bisher bei jedem Aufruf tat. Fehlen die Dateien, zeigt die Website die eingestellte Ersatzschrift und sagt es dir. Geholt werden sie wie bisher einmalig unter **Design → Schriften ausliefern**. Zusätzlich wird jetzt **jeder** Schnitt des Designs vorab geladen: bisher waren es die ersten zwei je Familie, weshalb die Überschriften sichtbar von der Ersatzschrift auf die richtige Schrift sprangen.
|
||||
|
||||
**Offene Hinweise stehen auf jedem Verwaltungsbildschirm**, mit dem Knopf daneben: „Fassung X ist verfügbar“ → *Jetzt aktualisieren*, „Schriftdateien fehlen“ → *Schriften holen*. Keiner der beiden fragt beim Öffnen im Netz nach.
|
||||
|
||||
**System und Update** ist auf dem Telefon benutzbar: Die Seite lief seitlich aus dem Bild, die Sicherungstabelle wird zu gestapelten Blöcken, und die Texte sind knapper – das Kleingedruckte steht zugeklappt.
|
||||
|
||||
**Im Designbereich** zeigen die Gerätechips **D/T/M** jetzt in Orange, welches Gerät einen eigenen Wert trägt; das gerade bearbeitete Gerät hat einen Rahmen statt des bisherigen roten Rings, der am Rand der Spalte abgeschnitten wurde.
|
||||
|
||||
## Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten (ab 1.14.0)
|
||||
|
||||
Unter **Einstellungen → Unternehmensdaten** gibt es **Öffnungszeiten** (je Wochentag Öffnen und Schließen; leer heißt geschlossen) und das **Einsatzgebiet** (ein Ort je Zeile). Beides erscheint in den strukturierten Daten der Startseite (`openingHoursSpecification`, `areaServed`); gleiche Zeiten werden zu einer Angabe zusammengefasst. Der sichtbare Text auf der Website bleibt unabhängig davon.
|
||||
|
||||
Ein Akkordeon wird per Kästchen **Als FAQ auszeichnen** zur `FAQPage`: die Titel sind die Fragen, die Textbausteine im Eintrag die Antwort. Einträge ohne Text erscheinen nicht. Das gilt nur für Akkordeons mit gesetztem Kästchen – ein Akkordeon mit Leistungen bleibt ein Akkordeon.
|
||||
|
||||
## Mehr Symbole, Menü-Knopf gestalten, Nach-oben-Knopf zuverlässig (ab 1.13.0)
|
||||
|
||||
Der Symbolsatz hat drei Zeichnungen mehr: **Menschen**, **Musik** und **Haus**. Sie stehen überall zur Wahl, wo es Symbole gibt – Symbolbaustein, Symbolkarte und Menüeinträge. Eine am Symbolbaustein eingestellte **Farbe** wirkt jetzt auch wirklich auf die Zeichnung; bisher blieb sie in der Akzentfarbe der Website stecken.
|
||||
|
||||
Der **Menü-Knopf** (der aufklappbare „Menü“-Knopf auf Tablet und Telefon) lässt sich gestalten: Navigation auswählen → **Inhalt → Menü-Knopf** wählt zwischen *Symbol und Text*, *Nur Text* und *Nur Symbol*; „Nur Symbol“ behält den Namen für Vorlesesoftware. Unter **Design → Menü-Knopf** stehen Schriftfarbe, Fläche, Rahmenfarbe, Rahmenstärke, Ecken und Schriftgröße, je Gerät und mit Palettenfarben. Das aufgeklappte Menü legt sich im Kopfbereich als Fläche über die volle Breite unter den Kopf, statt die Kopfzeile auseinanderzuschieben.
|
||||
|
||||
Der **Nach-oben-Knopf** erscheint wieder überall. Er blendete sich über eine Scroll-Animation ein; Browser, die diese Eigenschaft zwar kennen, aber nicht ausführen, ließen ihn dauerhaft unsichtbar. Jetzt entscheidet eine Klasse, die das kleine Scroll-Skript setzt – und ohne JavaScript bleibt der Knopf einfach sichtbar.
|
||||
|
||||
## Soziale Profile (ab 1.12.0)
|
||||
|
||||
Unter **Einstellungen → Unternehmensdaten → Soziale Profile** je Netzwerk eine Adresse eintragen (Facebook, Instagram, LinkedIn, YouTube, TikTok, X, Threads, Pinterest, Xing, WhatsApp, Google-Unternehmensprofil). Das Element **Soziale Profile** (Werkzeugkasten → Bausteine) zeigt sie dann an jeder Stelle – im Kopf- oder Fußbereich, in einer Kontaktsektion – und bestimmt dort Größe, Form (nur Symbol, runde oder eckige Fläche), Anordnung, Beschriftung und ob nur bestimmte Netzwerke erscheinen. Farbe, Abstand und Ausrichtung wie bei jedem Element unter **Design**. Eine Adresse ändern heißt: überall geändert. Die Profile stehen automatisch in den strukturierten Daten (`sameAs`). Ohne Eintrag zeigt das Element auf der Website nichts.
|
||||
|
||||
## Slider-Steuerung auf dem Bild (ab 1.11.4)
|
||||
|
||||
Die Steuerung liegt jetzt auf dem Slider, wie beim Hero-Slider von kant-it.de: runde weiße Pfeile links und rechts in der Mitte, Punkte unten rechts mit dem aktiven Punkt als Strich in der Akzentfarbe, ein kleiner Pause-Knopf daneben, kein Zähler mehr (der bleibt für Vorlesesoftware). Auf Bildschirmen bis 600 px verschwinden die Pfeile, gewischt wird mit dem Finger. Unter **Design → Slider-Steuerung** gibt es zusätzlich „Aktiver Punkt“.
|
||||
|
||||
## Farben der Slider-Steuerung (ab 1.11.3)
|
||||
|
||||
Slider auswählen → **Design → Slider-Steuerung**: Farbe der Punkte, Farbe von Pfeilen und Zähler sowie Hintergrund der Knöpfe, je Gerät und mit Palettenfarben. Ohne eigene Werte bleiben die Punkte in der Textfarbe der Sektion und die Knöpfe weiß mit dunkler Schrift. Die Farben gelten auch für die skriptfreien Punkte, die der Browser selbst zeichnet.
|
||||
Slider auswählen → **Design → Slider-Steuerung**: Farbe der Punkte, Farbe von Pfeilen und Zähler sowie Hintergrund der Knöpfe, je Gerät und mit Palettenfarben. Ohne eigene Werte sind die Punkte halbtransparent weiß und die Pfeile weiß mit Akzentfarbe. Die Farben gelten auch für die skriptfreien Punkte, die der Browser selbst zeichnet.
|
||||
|
||||
## Slider auf dem iPhone (ab 1.11.2)
|
||||
|
||||
@@ -120,9 +220,11 @@ Unter `http://127.0.0.1:8080/admin` eine Seite auswählen. Links Blöcke hinzuf
|
||||
|
||||
**Links und Bildvorschauen (ab 1.7.3):** Unter Linkziel steht **In neuem Tab öffnen** – für Buttons, verlinkte Bilder, Symbole, Symbolkarten und Aufruf-Bänder. Im Text zuerst den Link über die Werkzeugleiste öffnen; bei Menüeinträgen steht das Häkchen in den aufgeklappten Einstellungen. Ausgewählte Bilder und Galerie-/Logobilder zeigen eine Vorschau mit Entfernen-Funktion. „Bild entfernen“ entfernt das Bild aus dem Inhalt; die Datei bleibt in der Mediathek. Rückgängig stellt die Verwendung wieder her. Design → Hintergrund zeigt auch geerbte Bilder: Entfernen blendet das Bild auf dem aktiven und kleineren Geräten aus, Zurücksetzen stellt die Vererbung wieder her. Bildaustausch erhält Link, Bildunterschrift und eingestellte Größe. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:link-media`, Bericht und Screenshots unter `reports/link-media/`. Datenvertrag und Rückweg: ADR 081 in `docs/ARCHITECTURE.md`.
|
||||
|
||||
**Symbole (ab 1.25.0):** Button, Symbol oder Symbolkarte auswählen → Inhalt → **Symbol** → **Ändern …**. Das Fenster zeigt 98 Symbole in sieben Kategorien, mit Namen; Pfeiltasten und Escape funktionieren, beim Button gibt es „Kein Symbol“. Am Button außerdem **Stellung** (vor/nach dem Text), **Beim Überfahren** (Ruhig, Rückt, Erscheint) und **Nur das Symbol zeigen** (quadratischer Knopf, die Beschriftung bleibt für Vorlesesoftware). Farbe, Fläche, Größe, Ecken, Drehung und Abstand stehen unter **Design → Symbol**, je Gerät und beim Überfahren. Ohne eigene Wahl trägt ein Button wie bisher den schrägen Pfeil. Neue Symbole kommen nach `public/assets/icons.js`, danach `npm run build:icons`. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:button-icons`, Screenshots unter `reports/button-icons/`. Datenvertrag: ADR 108 und 109.
|
||||
|
||||
Blöcke per Drag-and-drop verschieben: auf einen Container ziehen, um hineinzuverschieben; auf einen Text-/Bild-/Linkblock ziehen, um davor einzufügen. Alternativ unten am Strukturbaum die Schaltflächen für Auf/Ab, Verschachteln und Herausheben verwenden. Duplizieren vergibt neue Block-IDs und wandelt eine kopierte H1 in H2 um. Löschen lässt sich rückgängig machen. Jede speicherbare Seite benötigt genau eine H1.
|
||||
|
||||
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen.
|
||||
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen; was Redakteur, Veröffentlicher und Administrator dürfen, steht unter Benutzer bei jeder Rolle und im Abschnitt „Rollen“ oben.
|
||||
|
||||
Ein Speicherkonflikt überschreibt nichts: lokale Änderungen als JSON sichern und den Serverstand bewusst neu laden. Auch Netzwerk-/Sitzungsfehler lassen den lokalen Stand erhalten. Die Vorschau behält bei ungültigen Eingaben die letzte gültige Ausgabe.
|
||||
|
||||
@@ -188,6 +290,8 @@ Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --w
|
||||
|
||||
Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse.
|
||||
|
||||
Die öffentliche Adresse der Website steht als `SITE_ORIGIN` in `.env` – mit oder ohne `www`, so wie sie in Canonical, Sitemap, Feed und strukturierten Daten stehen soll. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt sie; ohne Argument zeigt der Befehl die aktuelle. Dasselbe geht in der Verwaltung unter „System“, mit einem Link auf die andere Schreibweise. Dort wird vorher geprüft, ob die Website unter der neuen Adresse antwortet. Die jeweils andere Schreibweise (`meine-firma.de` ↔ `www.meine-firma.de`) leitet die Anwendung selbst mit 301 auf die eingetragene um, mit Pfad und Abfrage, auch für die Verwaltung. Ein ganz anderer Host – die Vorschauadresse des Hosters, eine IP-Adresse, `localhost` – bleibt erreichbar, damit eine falsch eingetragene Adresse niemanden aussperrt; der Bildschirm „System“ zeigt dann, unter welcher Adresse die Verwaltung gerade läuft und welche eingetragen ist. HTTPS erzwingt die Anwendung nicht; dafür bleiben die drei Zeilen in `public/.htaccess`.
|
||||
|
||||
`composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen.
|
||||
|
||||
`test:links` folgt der Sitemap, sammelt jeden internen Verweis samt Bildern, Stylesheets und Hintergrundbildern und meldet jede Antwort ab 400 – gebaut, nachdem im Nachbau von kant-it.de vier Verweise ins Leere zeigten, die kein Test finden konnte.
|
||||
@@ -237,7 +341,7 @@ Serverkonfiguration über die nicht öffentliche `.env`; echte Server-Umgebungsv
|
||||
|
||||
**Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-<Fassung>.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Entwicklungsskripte und kein `var/`. Eine `.env` aus der unvertraulichen `.env.example` liegt für die Erstinstallation bei. Der Webinstaller schreibt die eigenen Werte in `.env`, migriert und legt das erste Benutzerkonto an. Beim Update die eigene `.env` erhalten und niemals mit der Vorlage aus dem Archiv überschreiben. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
|
||||
|
||||
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
|
||||
`web.kant-it.de` und das Drews-Hosting laufen mit Releasearchiven dieses Codes; die Domainumschaltung von kant-it.de, ein Restore-Test auf dem Hosting und der Formularversand über einen echten Mailserver stehen aus (`docs/LIVEGANG.md`). Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. Migration `20261009000100` legt die Tabelle `page_translation` für die Mehrsprachigkeit an (neue Tabelle, kein Eingriff in Bestehendes; Sprachen unter Einstellungen → Sprachen, ADR 111). Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
|
||||
|
||||
## Verwaltung ab 1.7.0
|
||||
|
||||
@@ -267,6 +371,34 @@ In der App-Vorschau erledigt das die Startkonfiguration `elektro-bernhard` in `.
|
||||
|
||||
Der Bestand wird nicht von Hand gepflegt, sondern aufgebaut: `scripts/seed-elektro-bernhard.php <Ordner mit den Originalbildern>` legt Gestaltung, Medien, Formular, Kopf- und Fußbereich, elf Seiten, vier Menüs, Einstellungen, Weiterleitungen und ein Administratorkonto an. Das Skript weigert sich, wenn die Datenbank schon Seiten enthält oder `DATABASE_URL` nicht auf diese Installation zeigt. `sh scripts/reset-elektro-bernhard.sh <Bilderordner>` wirft die Installation weg, migriert neu und baut sie wieder auf; das Passwort des Kontos steht am Ende der Ausgabe. Vorlage ist das statische Paket `Elektro-Bernhard-Website`; die drei Stellen, an denen der Nachbau bewusst abweicht, stehen in `docs/STATUS.md`.
|
||||
|
||||
### Dritte lokale Referenzwebsite: Tagespflege Moldovan
|
||||
|
||||
Der Onepager „Tagespflege Jacqueline Moldovan“ liegt in `var/tagespflege-moldovan.db` mit Medien in `var/tagespflege-moldovan-media`; die Startkonfiguration `tagespflege-moldovan` in `.claude/launch.json` nutzt Port 8089. Der Inhalt entsteht ausschließlich über `scripts/seed-tagespflege-moldovan.php <Ordner mit gemeinsam.webp, jacqueline-moldovan.png und marke.png>`; `sh scripts/reset-tagespflege-moldovan.sh [Ordner]` (Standard `var/tagespflege-moldovan-assets`) wirft die Installation weg, migriert neu und baut sie wieder auf. Vorlage war das gelieferte Paket `Tagespflege_Moldovan_Website.zip` (09.09.2026); die bewussten Abweichungen stehen im Kopf des Skripts.
|
||||
|
||||
### Vierte lokale Referenzwebsite: Ihr Malerservice Drews
|
||||
|
||||
Der Nachbau von maler-drews.com (Malermeister Axel Drews, Gernsbach) liegt in `var/maler-drews.db` mit Medien in `var/maler-drews-media`; die Startkonfiguration `maler-drews` in `.claude/launch.json` nutzt Port 8093. `scripts/seed-maler-drews.php <Bilderordner>` baut Gestaltung, Kopf- und Fußbereich, acht Seiten, 31 Beiträge in zwei Kategorien, Menüs, Formular, Einstellungen und Weiterleitungen auf; der Wortlaut der Beiträge, des Impressums und der Datenschutzerklärung steht in `scripts/seed-maler-drews-inhalte.php`. `sh scripts/reset-maler-drews.sh [Bilderordner]` (Standard `var/maler-drews-assets`, Zuordnung in `manifest.json`) wirft die Installation weg und baut sie neu auf; die Schriften Lato 400/700 und Cormorant Garamond 700 holt es nur, wenn die Cormorant-Dateien in `public/assets/fonts` fehlen, weil `cms:fonts:fetch` die geteilte `fonts.css` sonst nur für dieses Design neu schreibt (Kant-IT und Elektro Bernhard brauchen Lato 900). Für den Abruf liegt `var/cacert.pem` (curl.se) bereit, weil das PHP 8.4 auf diesem Rechner keine Stammzertifikate kennt. Die bewussten Abweichungen vom Original stehen im Kopf des Skripts.
|
||||
|
||||
### Fünfte lokale Referenzwebsite: DEEG Immobilien & Hausverwaltung
|
||||
|
||||
Der Onepager „DEEG – Immobilien & Hausverwaltung in Freudenstadt“ (Deeg Hausverwaltung und Immobilien GmbH) liegt in `var/immobilien-deeg.db` mit Medien in `var/immobilien-deeg-media`; die Startkonfiguration `immobilien-deeg` in `.claude/launch.json` nutzt Port 8094, `immobilien-deeg-prod` denselben Bestand im Produktionsmodus auf Port 8095 zum Messen. `scripts/seed-immobilien-deeg.php <Bilderordner>` baut Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Menü, Einstellungen und zwei Formulare auf – `kontakt` und `bewertung` (Bewertungsanfrage mit Auswahl Wohnung, Haus, Mehrfamilienhaus, Grundstück); `sh scripts/reset-immobilien-deeg.sh [Bilderordner]` (Standard `var/immobilien-deeg-assets`: Logo und zwei Architekturfotos aus dem gelieferten Paket) wirft die Installation weg und baut sie neu auf. Die Vorlage nutzt Arial, es gibt keine Schrift zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts.
|
||||
|
||||
### Sechste lokale Referenzwebsite: EB Solar
|
||||
|
||||
Der Redesign-Entwurf von ebsolar.de (Elektro Bernhard Regenerative Energie GmbH, Baden-Baden) liegt in `var/ebsolar.db` mit Medien in `var/ebsolar-media`; die Startkonfiguration `ebsolar` in `.claude/launch.json` nutzt Port 8096, `ebsolar-prod` denselben Bestand im Produktionsmodus auf Port 8097 zum Messen. `scripts/seed-ebsolar.php <Bilderordner>` baut Gestaltung, Kopf- und Fußbereich, den Kontaktbereich als wiederverwendbare Sektion, Startseite, Projektübersicht, Solarcheck, Impressum, Datenschutz, 404, acht Projektbeiträge mit Galerie und Bildbetrachter, Menü, Einstellungen und zwei Formulare auf – `kontakt` und `solarcheck` (acht Schritte wie das Original auf ebsolar.de, die fünf Auswahlfragen als Bildkarten – gezeichnet und gerastert von `node scripts/build-ebsolar-options.mjs`); `sh scripts/reset-ebsolar.sh [Bilderordner]` (Standard `var/ebsolar-assets`: Logo, Favicon, Startbild, sechs Herstellerlogos, 31 Projektfotos und `projects.json` aus dem gelieferten Paket) wirft die Installation weg und baut sie neu auf. Die Vorlage nutzt Arial, es gibt keine Schrift zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts.
|
||||
|
||||
### Siebte lokale Referenzwebsite: KANTARCI Immobilien (Landingpage)
|
||||
|
||||
Die Landingpage „KANTARCI Immobilien – Mietanfrage“ (KANTARCI Immobilien GmbH, Baden-Baden) liegt in `var/kantarci-immobilien.db` mit Medien in `var/kantarci-immobilien-media`; die Startkonfiguration `kantarci-immobilien` in `.claude/launch.json` nutzt Port 8098, `kantarci-immobilien-prod` denselben Bestand im Produktionsmodus auf Port 8099 zum Messen. `sh scripts/reset-kantarci-immobilien.sh [Bilderordner]` (Standard `var/kantarci-immobilien-assets` mit `kantarci-banner.jpeg` aus dem gelieferten Paket) schneidet Logo, Hausmotiv und Seitensymbol aus dem Banner (`scripts/build-kantarci-assets.php`), zeichnet die Bildkarten der Mietanfrage (`node scripts/build-kantarci-options.mjs`), wirft die Installation weg und baut mit `scripts/seed-kantarci-immobilien.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Einstellungen und das Formular `mietanfrage` (drei Schritte mit Bildkarten und Zusammenfassung) auf. Schriften sind Arial und Georgia, es gibt nichts zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts.
|
||||
|
||||
### Achte lokale Referenzwebsite: Kantarci Markt (Vorab-Seite zur Neueröffnung)
|
||||
|
||||
Die Seite „Kantarci Markt – Neueröffnung“ (Wilhelm-Drapp-Straße, Baden-Baden) liegt in `var/kantarci-markt.db` mit Medien in `var/kantarci-markt-media`; die Startkonfiguration `kantarci-markt` in `.claude/launch.json` nutzt Port 8100, `kantarci-markt-prod` denselben Bestand im Produktionsmodus auf Port 8101 zum Messen. `sh scripts/reset-kantarci-markt.sh [Bilderordner]` (Standard `var/kantarci-markt-assets` mit `kantarci-markt-logo.png`, `market.jpg` und `vegetables.jpg` aus dem gelieferten Paket) schneidet das Logo zu und rastert Seitensymbol und Stempel (`node scripts/build-kantarci-markt-assets.mjs`), wirft die Installation weg, baut mit `scripts/seed-kantarci-markt.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404 und Einstellungen auf und holt DM Sans auf den eigenen Server (`cms:fonts:fetch`, ersetzt in `fonts.css` nur die Regeln für DM Sans). Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8101 CMS_PATHS=/,/impressum,/datenschutz CMS_AUDIT_DIR=reports/lighthouse-kantarci-markt node scripts/audit.mjs` (in Git Bash verhindert `MSYS_NO_PATHCONV`, dass `/impressum` zu einem Windows-Pfad wird). Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts. Die Demo der Eröffnungsseite liegt unter `/eroeffnung` (noindex) und bettet die Angebote aus `app.kantarci-markt.de` ein; die App erlaubt das nur von `localhost`, deshalb misst man sie mit `CMS_HOST_RULES="MAP localhost 127.0.0.1" CMS_URL=http://localhost:8101 CMS_PATHS=/eroeffnung`.
|
||||
|
||||
### Neunte lokale Referenzwebsite: Hüfner Gartenbau
|
||||
|
||||
Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `var/huefner.db` mit Medien in `var/huefner-media`; die Startkonfiguration `huefner` in `.claude/launch.json` nutzt Port 8102, `huefner-prod` denselben Bestand im Produktionsmodus auf Port 8103 zum Messen. `sh scripts/reset-huefner.sh [Entwurfsordner]` kopiert die Fotos aus dem gelieferten Entwurf nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argument nimmt es die schon vorbereiteten Bilder), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8103 CMS_PATHS=/,/impressum,/datenschutz npm run audit`. Die bewussten Abweichungen vom Entwurf stehen im Kopf des Skripts.
|
||||
|
||||
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
|
||||
|
||||
Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`.
|
||||
@@ -8,7 +8,9 @@ framework:
|
||||
cookie_httponly: true
|
||||
cookie_samesite: lax
|
||||
cookie_secure: auto
|
||||
cookie_path: /admin
|
||||
# Der Pfad folgt der Adresse der Verwaltung: hiesse sie anders und das Cookie weiter
|
||||
# /admin, schickte der Browser es nie mit, und keine Anmeldung kaeme je an.
|
||||
cookie_path: '/%kant.admin_path%'
|
||||
name: KANTSESSION
|
||||
router:
|
||||
utf8: true
|
||||
@@ -19,6 +21,7 @@ framework:
|
||||
log: true
|
||||
mailer:
|
||||
# Default to the host's sendmail, which is what ordinary PHP hosting offers. Override with
|
||||
# MAILER_DSN in .env.local for SMTP. Delivery failures never lose a submission: it is
|
||||
# MAILER_DSN in .env for SMTP. Delivery failures never lose a submission: it is
|
||||
# stored first and stays visible as undelivered in the administration.
|
||||
dsn: '%env(resolve:MAILER_DSN)%'
|
||||
# Ohne `resolve:`: ein SMTP-Passwort mit Prozentzeichen ist kein Parametername.
|
||||
dsn: '%env(MAILER_DSN)%'
|
||||
@@ -1,5 +1,8 @@
|
||||
twig:
|
||||
default_path: '%kernel.project_dir%/templates'
|
||||
# Die Vorlagen der Plugins (@plugins/<name>/...) registriert App\Twig\PluginExtension zur
|
||||
# Laufzeit - nur wenn der Ordner plugins/ existiert; ein fester Pfad hier liesse Twig auf einer
|
||||
# Installation ohne Plugins abbrechen.
|
||||
strict_variables: true
|
||||
globals:
|
||||
# Reading the decision needs the current request, so the templates ask the gate directly
|
||||
|
||||
+40
-7
@@ -26,14 +26,21 @@ services:
|
||||
exclude: '../src/{Kernel.php,Domain/}'
|
||||
Doctrine\DBAL\Connection:
|
||||
factory: ['App\Infrastructure\ConnectionFactory', 'create']
|
||||
arguments: ['%env(resolve:DATABASE_URL)%', '@App\Infrastructure\QueryCounter']
|
||||
arguments: ['%env(projectdir:DATABASE_URL)%', '@App\Infrastructure\QueryCounter']
|
||||
App\Controller\PageController:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
# Mit oder ohne www entscheidet SITE_ORIGIN; die andere Schreibweise leitet dorthin um.
|
||||
App\EventSubscriber\CanonicalHostSubscriber:
|
||||
arguments:
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
App\Content\MediaLibrary:
|
||||
arguments:
|
||||
$directory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
$directory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
App\Content\DocumentLibrary:
|
||||
arguments:
|
||||
$directory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
# Die Schriften liegen im oeffentlichen Ordner: sie sollen vom Webserver direkt ausgeliefert
|
||||
# werden, ohne PHP dazwischen - das ist der Punkt der Uebung.
|
||||
# Die Stellen, die wissen muessen, wie die Verwaltung heisst.
|
||||
@@ -56,6 +63,9 @@ services:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
App\EventSubscriber\ErrorLogSubscriber:
|
||||
arguments:
|
||||
$debug: '%kernel.debug%'
|
||||
App\Controller\SeoController:
|
||||
arguments:
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
@@ -67,20 +77,30 @@ services:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
$databaseUrl: '%env(DATABASE_URL)%'
|
||||
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\Command\AdminPathCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$adminPath: '%kant.admin_path%'
|
||||
$environment: '%kernel.environment%'
|
||||
# Eigene kurze Zeitgrenzen statt des Download-Dienstes mit denen fuer Releasearchive.
|
||||
App\Ops\OriginCheck:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
$download: null
|
||||
App\Command\SiteOriginCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
App\Ops\ArchiveUpdate:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
App\Ops\Updater:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$databaseUrl: '%env(resolve:DATABASE_URL)%'
|
||||
$databaseUrl: '%env(projectdir:DATABASE_URL)%'
|
||||
App\EventSubscriber\InstallSubscriber:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
@@ -91,13 +111,13 @@ services:
|
||||
App\Ops\Installation:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
$secret: '%kernel.secret%'
|
||||
App\Ops\Backup:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$databaseUrl: '%env(resolve:DATABASE_URL)%'
|
||||
$mediaDirectory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
$databaseUrl: '%env(projectdir:DATABASE_URL)%'
|
||||
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
# Der Aufgabenplan. Die Aufgaben kommen ueber ihr Etikett herein, damit diese Stelle nicht
|
||||
# jedes Mal mitwaechst.
|
||||
App\Ops\Scheduler:
|
||||
@@ -118,6 +138,19 @@ services:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
App\Infrastructure\QueryCounter: ~
|
||||
# `%env(projectdir:...)%`: ersetzt nur %kernel.project_dir%. Symfonys `resolve:` laese ein
|
||||
# prozentkodiertes MySQL-Passwort (`p%40ss`) als Parameter und brauche den Container.
|
||||
App\Infrastructure\ProjectDirEnvProcessor:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\EventSubscriber\QueryHeaderSubscriber:
|
||||
arguments:
|
||||
$environment: '%kernel.environment%'
|
||||
|
||||
App\Plugin\PluginRegistry:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\Twig\PluginExtension:
|
||||
arguments:
|
||||
$loader: '@twig.loader.native_filesystem'
|
||||
+316
-5
@@ -1,6 +1,315 @@
|
||||
# Architekturentscheidungen
|
||||
|
||||
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
Stand: 12.10.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -371,7 +680,7 @@ Ein neuer Tab-Block bringt zwei Tabs mit: ein einzelner Tab ist keine Tabgruppe,
|
||||
|
||||
## ADR 017 – Formulare (Dokumentschema v8, 12.09.2026)
|
||||
|
||||
Migration `20260912000700` legt `form` und `form_submission` an. **Ein Formularblock im Seiteninhalt enthält nur die Adresse des Formulars.** Felder, Empfänger, Betreff, Antwortadresse und Einwilligungstext liegen in der Datenbank. Ein gespeichertes Dokument kann dadurch weder ändern, wonach gefragt wird, noch wohin eine Anfrage geht – dieselbe Trennung wie beim Block `postlist`.
|
||||
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.
|
||||
|
||||
@@ -419,7 +728,7 @@ Das Stylesheet steht aus Geschwindigkeitsgründen inline und ist **über seinen
|
||||
|
||||
`Theme` ist die Grundlage darüber: eine frei definierbare Farbpalette, Schriften und die Grundtypografie. Farben werden als `@schlüssel` referenziert und zu CSS-Variablen; eine Farbe zu ändern wirkt überall, wo sie benutzt wird. Die Überschriftengrößen entstehen aus einer modularen Skala, sodass eine Zahl die ganze Hierarchie verschiebt.
|
||||
|
||||
**Google Fonts auf ausdrückliche Entscheidung von Umut.** Das bedeutet eine Anfrage an Google bei jedem Seitenaufruf, mit den bekannten Folgen für Datenschutz und Ladezeit – der Entwicklungsplan verlangt eigentlich lokale Schriften. Umgesetzt ist es so, dass nur die tatsächlich benötigten Schnitte geladen werden und die Richtlinie ihre Hosts nur dann öffnet, wenn wirklich eine Google-Schrift eingestellt ist. Der Weg zum Selbsthosten bleibt offen: es wäre ein zusätzlicher Quelltyp neben `system` und `google`.
|
||||
**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.
|
||||
|
||||
@@ -524,6 +833,8 @@ Umut wollte an jedem Element eigenes CSS **und** eigenes Skript setzen können.
|
||||
|
||||
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.
|
||||
@@ -867,7 +1178,7 @@ Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Ein Favicon is
|
||||
|
||||
Naheliegend wäre gewesen, die WebP-Varianten der Mediathek zu verwenden. Dagegen sprechen zwei Dinge: die kleinste Variante ist 480 px breit (für ein 32-px-Symbol zwei Größenordnungen zu viel), und ein Favicon ist **quadratisch** – ein breites Logo würde gestaucht oder zur Briefmarke.
|
||||
|
||||
**Deshalb entstehen die Symboldateien beim Speichern der Einstellung, nicht beim Aufruf**: ein mittiger quadratischer Ausschnitt in 32 px (Reiter) und 180 px (Startbildschirm), als PNG, weil das jeder Browser liest. Schlägt das fehl, wird die Einstellung gar nicht erst gespeichert – ein Verweis auf ein Symbol, das es nicht gibt, wäre schlechter als das alte Symbol. Ausgeliefert werden sie unter `/favicon.png` und `/apple-touch-icon.png`, mit der Bildkennung als Version am Verweis, damit ein Wechsel im Browser ankommt.
|
||||
**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.
|
||||
|
||||
@@ -1063,7 +1374,7 @@ Umut: „Baue mal lokal kant-it.de komplett nach. Ich möchte sehen ob das funkt
|
||||
|
||||
Umut: „Einen Installer brauche ich noch wie wordpress schritt für schritt alles einrichten migration ausführen usw.“ und, später, „einen installer das eine php ist und alle dateien herunterladet“. Beides ist gebaut, und beides hängt an derselben Frage: was darf eine Datei dürfen, die auf einem leeren Webspace liegt und von jedem aufrufbar ist?
|
||||
|
||||
**Der Ablauf schreibt jeden Schritt sofort**, statt Antworten in einer Sitzung zu sammeln. Nach dem Datenbankschritt gibt es `.env.local`, nach dem Migrationsschritt Tabellen, nach dem Websiteschritt Inhalte. Ein Neuladen, ein Zeitüberlauf, ein geschlossener Tab verliert damit nichts – der Installer sieht, was schon da ist, und macht an der nächsten fehlenden Stelle weiter. Wer eine spätere Adresse rät, wird zurückgeschickt, und zwar vom Server, nicht von einem Link.
|
||||
**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.
|
||||
|
||||
|
||||
+3
-1
@@ -86,6 +86,8 @@ Abnahme: Die Startseite des Piloten ist ohne Code nachbaubar. Keine neue öffent
|
||||
|
||||
### D – Formulare — **erledigt am 12.09.2026, ohne Dateiuploads**
|
||||
|
||||
**Ergänzt am 15.09.2026 (ADR 101):** Feldtypen Zahl, Optionsfelder und Mehrfachauswahl, Schritte, Bedingungen auf frühere Auswahlfelder und eine Zusammenfassung vor dem Absenden – für den Preisfinder der DEEG-Website, als Ausbau des Kerns statt als Plugin. Dateiuploads bleiben offen.
|
||||
|
||||
*Braucht: nichts; das größte eigenständige Paket.*
|
||||
|
||||
Umgesetzt: Feldtypen Text, E-Mail, Telefon, Mehrzeiliges, Auswahl und Einwilligung, serverseitige Validierung mit Meldung je Feld, Einwilligungszeitpunkt, E-Mail-Versand mit Antwortadresse, gespeicherte Einsendungen in der Verwaltung und Spamschutz aus Honeypot, signiertem Zeitstempel und Rate-Limit. **Offen geblieben:** Dateiuploads (eigener nicht öffentlicher Ablageort und Typprüfung, Begründung in ADR 017), Löschen und Umbenennen von Formularen, Export und Aufbewahrungsfrist für Einsendungen. Der tatsächliche Versand ist ungeprüft und gehört zu Paket J.
|
||||
@@ -139,7 +141,7 @@ Umut hat den Umfang von G erweitert. Vier Punkte, in dieser Reihenfolge:
|
||||
|
||||
Abnahme: Kopf- und Fußbereich sind im Editor gestaltbar und öffentlich sichtbar. Eine verknüpfte Sektion auf zwei Seiten ändert sich an beiden Stellen gleichzeitig. Ein Textelement lässt sich formatiert und als HTML bearbeiten, und keine Auszeichnung außerhalb der Positivliste übersteht das Speichern. Eigenes CSS wirkt, eigenes Skript wirkt nur für Administratoren gesetzt und nur auf der veröffentlichten Seite. Die Verwaltung ist ohne Anleitung bedienbar.
|
||||
|
||||
### H – Plugins und Templates
|
||||
### H – Plugins und Templates — **erledigt am 16.09.2026** (ADR 102 und 103: Manifest, Schalter, geprüfte Bausteine, Dateien je Seite, Editoranbindung, Container, Einstellungen, eigene Adressen, Seitenvorlagen; Referenzplugin „Bildvergleich“. Designpakete in Plugins am selben Tag wieder entfernt – das Design gehört der Website, ADR 105. Bewusst nicht: PHP-Hooks und eigene Tabellen – Plugins bleiben Manifest plus Twig, ADR 103)
|
||||
|
||||
*Braucht: den Erhaltungsvertrag (erledigt).*
|
||||
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben
|
||||
|
||||
1. **E-Mail-Versand.** Nie ausgeführt, in keiner Umgebung. `MAILER_DSN=sendmail://default` funktioniert auf den meisten PHP-Hostings; verlangt der Anbieter SMTP, gehört dort eine Zeile der Form `smtp://benutzer:passwort@mailserver:587` hinein. **Nach dem Hochladen eine echte Anfrage über das Kontaktformular abschicken und prüfen, ob sie ankommt** – auch im Spam-Ordner. Die Anfrage wird vor dem Versand gespeichert; kommt keine Mail an, steht sie trotzdem unter „Formulare“ in der Verwaltung.
|
||||
2. **HTTPS.** Zertifikat einrichten, dann in `public/.htaccess` die drei Zeilen der HTTPS-Umleitung einschalten. **Erst wenn das eine Weile läuft**, die HSTS-Zeile aktivieren – sie lässt sich in einem Browser ein halbes Jahr lang nicht zurücknehmen.
|
||||
3. **www oder nicht.** Das alte kant-it.de ist kanonisch mit `www`. In `.htaccess` steht die Umleitung dafür vorbereitet und auskommentiert; sie muss zur Einstellung `SITE_ORIGIN` passen, die in `.env` steht. Beide zeigen auf dieselbe Schreibweise, sonst zeigt jede Seite auf eine Adresse, die weiterleitet.
|
||||
3. **www oder nicht.** Das alte kant-it.de ist kanonisch mit `www`. Die Entscheidung steht an einer Stelle: `SITE_ORIGIN` in `.env` (`php bin/console cms:site:origin https://www.kant-it.de`). Die jeweils andere Schreibweise leitet die Anwendung selbst mit 301 dorthin um; in `.htaccess` gibt es dafür keine Regel mehr. Der Bildschirm „System“ zeigt die eingetragene Adresse und warnt, wenn die Verwaltung gerade unter einer anderen läuft.
|
||||
4. **Datenbank.** Geprüft ist ausschließlich SQLite. Für MySQL ist alles vorbereitet, aber nichts erprobt – wer MySQL nimmt, sollte danach Seiten anlegen, veröffentlichen, Bilder hochladen und ein Formular absenden.
|
||||
5. **Wiederkehrende Aufgaben.** Sicherung, Statistik und geplante Veröffentlichung laufen nach dem Hochladen von selbst – angestoßen von Seitenaufrufen und vom geöffneten Backend (Verwaltung → Aufgaben). **Das genügt, ist aber nicht minutengenau:** ohne Besuch passiert nichts. Bietet der Hoster einen Cronjob, gehört `php bin/console cms:tasks:run` dorthin (jede Minute); bietet er nur einen URL-Cronjob, die Adresse vom Bildschirm „Aufgaben“ (alle fünf Minuten). Danach dort den Auslöser „Besuche der Website“ abschalten. **Nach dem Umzug einmal nachsehen**, ob in der Spalte „Zuletzt“ bei der Sicherung ein `ok` steht – ohne Sicherung gibt es keinen Rückweg.
|
||||
6. **Schriften.** Auf dem neuen Server einmal `php bin/console cms:fonts:fetch` ausführen oder im Designbereich den Knopf drücken – die Dateien liegen nicht im Repository. Bis dahin lädt die Seite die Schriften von Google, was sie nicht soll.
|
||||
@@ -30,7 +30,7 @@ Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben
|
||||
|
||||
## Optional: die Verwaltung umbenennen
|
||||
|
||||
`/admin` ist die erste Adresse, die jeder Scanner probiert. `php bin/console cms:admin:path meine-verwaltung` legt sie woanders hin; der Name steht danach in `.env.local` und in keiner öffentlichen Datei. **Vorher notieren, wohin** – danach antwortet `/admin` mit 404 wie jede unbekannte Adresse. Zurück geht es mit `php bin/console cms:admin:path admin`.
|
||||
`/admin` ist die erste Adresse, die jeder Scanner probiert. `php bin/console cms:admin:path meine-verwaltung` legt sie woanders hin; der Name steht danach in `.env` und in keiner öffentlichen Datei. **Vorher notieren, wohin** – danach antwortet `/admin` mit 404 wie jede unbekannte Adresse. Zurück geht es mit `php bin/console cms:admin:path admin`.
|
||||
|
||||
## Optional: Spamschutz mit Cloudflare
|
||||
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
# Plugins schreiben
|
||||
|
||||
Ein Plugin bringt eigene Bausteine, Vorlagen und Adressen in den Editor und auf die
|
||||
Website. Farben, Schriften und Buttons fasst es nie an – das Design gehört der Website (ADR 105). Es ist ein Ordner unter `plugins/` mit einer `plugin.json`, Twig-Vorlagen und optional
|
||||
Stylesheets und Skripten. Eingeschaltet und eingestellt wird es in der Verwaltung unter **Plugins**
|
||||
(Administrator). Hintergrund und Grenzen: ADR 102 und ADR 103 in `docs/ARCHITECTURE.md`.
|
||||
|
||||
**Ein Plugin bringt keinen PHP-Code mit.** Alles ist Manifest plus Twig. Deshalb gibt es keine
|
||||
Hooks in Speichern oder Veröffentlichen und keine eigenen Tabellen – und deshalb kann ein Update
|
||||
des Kerns ein Plugin nicht brechen, solange die Übergaben unten stehen.
|
||||
|
||||
## Der Ordner
|
||||
|
||||
```
|
||||
plugins/
|
||||
vergleich/ ← Name des Plugins: Kleinbuchstaben und Ziffern, mit Buchstabe beginnend
|
||||
plugin.json ← das Manifest
|
||||
blocks/bildvergleich.html.twig
|
||||
blocks/galerie.html.twig
|
||||
routes/daten.json.twig
|
||||
assets/bildvergleich.css ← nur unter assets/ liegt, was die Website ausliefert
|
||||
assets/bildvergleich.js
|
||||
```
|
||||
|
||||
Das mitgelieferte Plugin ist die Vorlage zum Abschreiben: `plugins/vergleich` (Bausteine,
|
||||
Container, Einstellungen, Adresse, Seitenvorlage).
|
||||
|
||||
## Das Manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "vergleich",
|
||||
"title": "Bildvergleich",
|
||||
"description": "Vorher und nachher in einem Bild.",
|
||||
"version": "1.1.0",
|
||||
"requires": "1.17.0",
|
||||
"settings": {
|
||||
"labelBefore": { "kind": "text", "label": "Standardbeschriftung vorher", "default": "Vorher" }
|
||||
},
|
||||
"blocks": {
|
||||
"bildvergleich": {
|
||||
"label": "Bildvergleich",
|
||||
"icon": "⇔",
|
||||
"template": "blocks/bildvergleich.html.twig",
|
||||
"css": "assets/bildvergleich.css",
|
||||
"script": "assets/bildvergleich.js",
|
||||
"props": {
|
||||
"before": { "kind": "media", "label": "Bild vorher" },
|
||||
"position": { "kind": "number", "label": "Startposition (%)", "min": 5, "max": 95, "default": 50 }
|
||||
}
|
||||
},
|
||||
"galerie": {
|
||||
"label": "Vergleichsgalerie",
|
||||
"template": "blocks/galerie.html.twig",
|
||||
"container": true,
|
||||
"children": ["vergleich.bildvergleich"],
|
||||
"props": { "columns": { "kind": "select", "label": "Spalten", "choices": { "2": "Zwei", "3": "Drei" } } }
|
||||
}
|
||||
},
|
||||
"presets": {
|
||||
"abschnitt": { "label": "Vorher-Nachher-Abschnitt", "blocks": [ { "id": "s", "type": "section", "children": [ ] } ] }
|
||||
},
|
||||
"routes": {
|
||||
"daten": { "path": "/vergleich/daten", "template": "routes/daten.json.twig", "type": "json" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `name` muss dem Ordnernamen entsprechen. Der Typ eines Bausteins ist `name.baustein`, hier
|
||||
`vergleich.bildvergleich`; so heißt er im Dokument und in der Ausgabe.
|
||||
- `version` in der Form `1.2.3`. Sie hängt an den Adressen der Dateien (`?v=1.1.0`), damit der
|
||||
Browser nach einem Update die neue Fassung holt.
|
||||
- `requires` (optional): die kleinste KantSites-Fassung. Passt sie nicht, erscheint das Plugin in
|
||||
der Verwaltung mit dem Grund und lässt sich nicht einschalten.
|
||||
- Ein Manifest braucht mindestens einen Baustein, eine Adresse oder eine Vorlage. Fehlt eine
|
||||
genannte Datei, ist das Manifest ungültig.
|
||||
|
||||
### Eigenschaften und Einstellungen (Schema)
|
||||
|
||||
Eigenschaften (`props` eines Bausteins) und Einstellungen (`settings` des Plugins) haben dasselbe
|
||||
Schema: eine Art (`kind`), eine Beschriftung (`label`), optional `help` und `default`.
|
||||
|
||||
| Art | Feld | Prüfung | Weitere Schlüssel |
|
||||
|---|---|---|---|
|
||||
| `text` | einzeilig | Text bis `maxLength` (Standard 200) | `maxLength` |
|
||||
| `textarea` | mehrzeilig | Text bis `maxLength` (Standard 2000) | `maxLength` |
|
||||
| `number` | Zahl | zwischen `min` und `max` | `min`, `max` |
|
||||
| `boolean` | Kästchen | an oder aus | – |
|
||||
| `select` | Liste | einer der Werte | `choices`: `{"wert": "Beschriftung"}` |
|
||||
| `url` | einzeilig | lokaler Pfad, `#sprungmarke`, `https://`, `mailto:`, `tel:` | `maxLength` |
|
||||
| `media` | Bild aus der Mediathek | Kennung eines Bildes oder leer | – |
|
||||
|
||||
Höchstens 30 Eigenschaften je Baustein. Unbekannte Eigenschaften in einem gespeicherten Dokument
|
||||
werden ignoriert (so kann ein Plugin eine Eigenschaft streichen), bekannte müssen passen, sonst
|
||||
lässt sich die Seite nicht speichern. Einstellungen bearbeitet der Administrator auf dem Bildschirm
|
||||
„Plugins“; sie werden genauso geprüft.
|
||||
|
||||
### Container
|
||||
|
||||
`"container": true` macht einen Baustein zum Behälter. `"children"` (optional) zählt auf, was
|
||||
hinein darf – Kernbausteine wie `text` oder Bausteine des Plugins; ohne die Liste darf alles hinein.
|
||||
Der Editor zeigt die Regel im Werkzeugkasten, der Server prüft sie beim Speichern.
|
||||
|
||||
### Seitenvorlagen (`presets`)
|
||||
|
||||
Fertige Bausteine, die der Editor als Ganzes einfügt – hinter den eingebauten Vorlagen mit demselben
|
||||
Knopf. `blocks` ist eine Liste von Bausteinen wie in einem Dokument (höchstens 64 KiB); Kennungen
|
||||
vergibt der Editor bei jedem Einfügen neu, du kannst also feste schreiben.
|
||||
|
||||
### Eigene Adressen (`routes`)
|
||||
|
||||
`path` hat die Form einer Seite (`/name` oder `/name/unter/seite`, Slugs) und darf nichts belegen,
|
||||
was der Kern beantwortet (`/admin`, `/media`, `/assets`, `/formular`, `/beitraege` …). `type` ist
|
||||
`html`, `json`, `text` oder `xml`. Eine veröffentlichte Seite mit derselben Adresse geht vor.
|
||||
Ausgeschaltet antwortet die Adresse mit 404; alles außer `html` trägt `noindex`.
|
||||
|
||||
### Was ein Plugin nicht kann: das Design
|
||||
|
||||
Es gibt kein `theme` im Manifest. Ein Plugin gestaltet seine eigenen Bausteine über deren
|
||||
Stylesheet und die Design-Tokens des Kerns; Palette, Schriften und Buttons der Website ändert
|
||||
nur der Betreiber unter **Design**. So färbt kein Schalter eine Website um, und nichts, was dort
|
||||
eingestellt wurde, geht durch ein Plugin verloren (ADR 105).
|
||||
|
||||
## Die Vorlagen
|
||||
|
||||
### Bausteine
|
||||
|
||||
Die Vorlage eines Bausteins bekommt genau sechs Dinge:
|
||||
|
||||
- `props` – die geprüften Eigenschaften, jede vorhanden, fehlende mit ihrem Vorgabewert
|
||||
- `settings` – die Einstellungen des Plugins, jede vorhanden
|
||||
- `children` – bei einem Container die fertig gerenderten Kinder (leer sonst); ausgeben mit
|
||||
`{{ children }}`
|
||||
- `block` – der Baustein selbst; `block.id` gehört als `id` an das Wurzelelement, sonst greifen
|
||||
eigenes CSS und die Editorauswahl nicht
|
||||
- `designClasses` – Klassen aus Sichtbarkeit, Design-Tokens und Einblendungen, an die `class`
|
||||
des Wurzelelements anhängen
|
||||
- `editing` – `true` in der Editorvorschau, für Hinweise wie „bitte ein Bild wählen“
|
||||
|
||||
Dazu eine Funktion: `kant_plugin_media(kennung)` liefert ein Bild der Mediathek als
|
||||
`{id, title, alt, variants: [{src, width, height}, …]}` (kleinste Fassung zuerst) oder `null`.
|
||||
|
||||
```twig
|
||||
{% set bild = props.before ? kant_plugin_media(props.before) : null %}
|
||||
<div id="{{ block.id }}" class="block-bildvergleich{{ designClasses }}" data-position="{{ props.position }}">
|
||||
{% if bild %}<img src="{{ (bild.variants|last).src }}" alt="{{ props.alt ?: settings.labelBefore }}">{% endif %}
|
||||
{{ children }}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Adressen
|
||||
|
||||
Die Vorlage einer Adresse bekommt `settings`, `query` (die Abfrageparameter mit sauberen Namen,
|
||||
gekürzt) und `route`. Für `json` und `xml` schreibt sie die Antwort selbst, etwa mit `json_encode`.
|
||||
|
||||
Was keine Vorlage bekommt: Datenbank, Einstellungen der Website, Anfrage. Sie läuft mit den
|
||||
Rechten der Anwendung – ein Sandkasten ist das nicht, und so wird es auch nicht genannt.
|
||||
|
||||
## Stylesheet und Skript
|
||||
|
||||
Beide werden nur auf Seiten geladen, auf denen der Baustein vorkommt, über
|
||||
`/plugin-assets/<name>/assets/...`. Die Content-Security-Policy erlaubt Skripte vom eigenen
|
||||
Ursprung, sobald die Seite eines braucht; Inline-Stile und Inline-Skripte bleiben verboten. Ein
|
||||
Skript setzt Werte deshalb über das CSSOM (`element.style.setProperty(...)`), nie über ein
|
||||
`style`-Attribut in der Vorlage. In der Editorvorschau wird das Stylesheet geladen, das Skript
|
||||
nicht – die Vorlage muss auch ohne Skript ein brauchbares Bild abgeben.
|
||||
|
||||
## Was der Kern übernimmt
|
||||
|
||||
- Speichern, Veröffentlichen, Fassungen, Undo/Redo – der Baustein ist ein gewöhnlicher Block.
|
||||
- Eigenes CSS je Block, Design-Tokens (Abstände, Ausrichtung, Ecken), Sichtbarkeit je Gerät,
|
||||
Einblendungen – alles über `designClasses` und die `id`.
|
||||
- Der Editor: Eintrag im Werkzeugkasten unter „Erweiterungen“, Eigenschaftenpanel aus dem
|
||||
Schema, Container-Regeln, Vorlagen, Vorschau über den Server.
|
||||
- Der Erhaltungsvertrag: ist das Plugin aus oder fehlt es, bleibt der Baustein im Dokument, der
|
||||
Editor zeigt einen Platzhalter, die Website einen HTML-Kommentar. Nichts geht verloren.
|
||||
|
||||
## Updates
|
||||
|
||||
Mitgelieferte Plugins liegen im Releasearchiv und werden beim Archiv-Update Datei für Datei
|
||||
aktualisiert. Ein selbst installiertes Plugin bleibt dabei stehen, weil sein Ordner im Archiv
|
||||
nicht vorkommt. Beim Git-Update ist ein fremder Ordner unter `plugins/` nicht von Git verwaltet;
|
||||
`git checkout` lässt ihn in Ruhe. Die Einstellungen liegen in der Datenbank und überleben beides.
|
||||
+23
-1
@@ -1,5 +1,27 @@
|
||||
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||
|
||||
## Ergänzung: DEEG Immobilien & Hausverwaltung gemessen, 15.09.2026 (1.17.0)
|
||||
|
||||
Lighthouse **13.4.1** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der fünften Referenzwebsite „DEEG – Immobilien & Hausverwaltung in Freudenstadt“ (`var/immobilien-deeg.db`, Startkonfiguration `immobilien-deeg-prod`, Port 8095), drei Adressen: `/`, `/impressum`, `/datenschutz`. Ergebnis: **Leistung, Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**; Startseite LCP 1,8 s mobil und 0,5 s Desktop (das Startfoto als `img` im Hero), CLS 0, TBT 0 ms. Ladebudget der Startseite 157 KB mit 6 Anfragen und 11 Datenbankabfragen. Berichte unter `reports/lighthouse-immobilien-deeg/`. Voraussetzung für die 100 bei Barrierefreiheit: Blau mit weißer Schrift als `#0076b6` statt Logo-Blau `#0081c7`, kleine blaue Schrift `#006aa3`, Copyright-Zeile mit eigener Farbe. Nach dem Umbau der Bewertungsanfrage zum dreistufigen Preisfinder (ADR 101, zweites Skript `form-steps.js` auf der Startseite) erneut gemessen: `/` mobil und Desktop weiterhin in allen fünf Kategorien 100, Ladebudget 161 KB mit 7 Anfragen.
|
||||
|
||||
## Ergänzung: Ihr Malerservice Drews gemessen, 15.09.2026 (1.16.0 + zwei unveröffentlichte Korrekturen)
|
||||
|
||||
Lighthouse **13.4.1** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der vierten Referenzwebsite „Ihr Malerservice Drews“ (`var/maler-drews.db`, Port 8092), sechs Adressen: `/`, `/aktuelles`, `/unsere-projekte`, `/kunstmalerei`, `/kontakt`, `/friedrichsbad-baden-baden`. Ergebnis: **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung 98–100 – Startseite 99 mobil und 98 Desktop, Kunstmalerei 99/99, Projekte und Beitrag 99 mobil, alle übrigen 100. Der Rest zur 100 ist überall das Foto hinter dem Seitenkopf als CSS-Hintergrund (LCP mobil 1,6–2,1 s, Desktop 0,6–1,0 s; CLS ≤ 0,01, TBT ≤ 7 ms). Berichte unter `reports/lighthouse-maler-drews/`.
|
||||
|
||||
Drei Messrunden führten dorthin. Die erste zeigte Barrierefreiheit 91–93: das Gold `#c6a36b` der Vorlage erreicht auf Weiß 2,4:1 (Kleinzeilen, Datum, Links, weiße Buttonschrift auf Gold), die Beitragslisten sprangen von H1 auf H3, der Copyright-Link hob sich nur durch Farbe ab, und der Nach-oben-Knopf lag außerhalb jeder Landmarke (axe „region“, auf jeder Seite). Behoben durch `@gold-text` (`#8a6a2f`, 5,0:1) für Text auf hellem Grund, dunkle Buttonschrift (6:1), eine unsichtbare H2 vor den Listen, Unterstreichung, und im CMS ein `nav` um den Knopf. Die zweite Runde ließ noch den aktiven Menüpunkt (Gold auf Weiß) und graue Formularbeschriftungen (`#999`, 2,8:1) übrig. Für die Leistung: nur das erste Hintergrundbild einer Seite wird vorgeladen – der Slider der Startseite liegt eine Ebene tiefer, deshalb trägt der äußere Abschnitt dasselbe Foto; jede im Design genannte Schriftstärke wird vorgeladen, deshalb Lato nur 400/700 (ohne das 300 der Vorlage, 23 KB weniger vor dem größten Bild); Kacheln und Galeriebilder bekamen feste Breiten, weil ohne sie eine 270 px breite Kachel die 2400er Datei lud (108 KB Ersparnis auf der Startseite laut `image-delivery`).
|
||||
|
||||
axe (WCAG 2.0/2.1 A+AA und Best-Practice-Regeln, Playwright, 1440 und 390 px, nach dem Durchscrollen und zwei Sekunden Wartezeit) auf neun Seiten einschließlich Impressum und Datenschutz: **null Verstöße** (`var/drews-axe.mjs`); die erste Runde hatte 58.
|
||||
|
||||
Maschinenlesbar: `llms.txt` (8 Seiten, 31 Beiträge mit Beschreibung), `robots.txt`, `sitemap.xml` (41 Adressen), `feed.xml`, `LocalBusiness` mit Adresse, Telefon, Öffnungszeiten Mo–Do/Fr, Einsatzgebiet und Instagram als `sameAs`. Nicht gemessen: Felddaten (LCP/INP/CLS am 75. Perzentil), Screenreader, Firefox/Safari, ein echtes Touchgerät, das tatsächliche Hosting.
|
||||
|
||||
## Ergänzung: Elektro Bernhard gemessen, 14.09.2026 (1.12.1)
|
||||
|
||||
Lighthouse **13** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der Referenzwebsite Elektro Bernhard (`var/elektro-bernhard.db`, Port 8090), vier Adressen: `/`, `/leistungen`, `/leistungen/notdienst`, `/kontakt`. Ergebnis nach den Korrekturen: **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung überall 100 bis auf die mobile Startseite mit **99** (LCP 2,1 s durch das Hero-Foto als Hintergrund unter Mobildrosselung; CLS 0, TBT 30 ms). Berichte unter `reports/lighthouse-elektro-bernhard-2/`; der erste Lauf davor (`reports/lighthouse-elektro-bernhard/`) zeigte Barrierefreiheit 95–97.
|
||||
|
||||
axe (WCAG 2.0/2.1 A+AA und Best-Practice-Regeln, Playwright, 1440 und 390 px) auf sechs Seiten: **null Verstöße**. Vorher gab es drei echte Befunde, alle behoben: Markennamen `#9aa0a6` auf Weiß (2,6:1 → `#6b7178`, 4,7:1), der rote Link „Kant-IT Solutions“ im dunklen Fuß (3,0:1 → hell), und auf der Leistungsübersicht sprang die Gliederung von H1 auf H3 (jetzt eine H2 vor den Karten). Ein vierter Befund lag im CMS: der Pause-Knopf des Sliders hatte den sichtbaren Text „Pause“, aber den zugänglichen Namen „Automatische Wiedergabe pausieren“ (WCAG 2.5.3); der Name beginnt jetzt mit dem sichtbaren Text. Achtung beim Messen: unmittelbar nach dem Laden meldet axe Kontrastverstöße an Elementen, die gerade eingeblendet werden (halb transparente Zwischenzustände); erst nach dem Durchscrollen und zwei Sekunden Wartezeit sind die Befunde belastbar.
|
||||
|
||||
Maschinenlesbar: `llms.txt`, `robots.txt`, `sitemap.xml` werden ausgeliefert, `LocalBusiness` mit drei `sameAs`-Profilen auf der Startseite, `BreadcrumbList` auf Unterseiten, `og:image` auf allen Inhaltsseiten. Nicht gemessen: Felddaten (LCP/INP/CLS am 75. Perzentil), Screenreader, Firefox/Safari, das tatsächliche Hosting.
|
||||
|
||||
## Ergänzung: Sichtbare leere Layouts, 14.09.2026 (1.6.2)
|
||||
|
||||
30 JavaScript-Tests und 27 PHP-Editor-/Sicherheitstests mit 312 Assertions bestanden. Vollständiger isolierter Chromium-Editortest einschließlich axe ohne Verstöße: direkte Tastatureinfügung in leere Sektionen/Spalten, genaues Drag-and-drop-Ziel und Scrollen am Rand, Spaltenreihenfolge auf Desktop und Mobil, Undo/Redo, Speichern/Neuladen, Veröffentlichung und Konflikte. Die Tests prüfen ausdrücklich, dass die neuen Editorhilfen weder im gespeicherten Blockbaum noch im öffentlichen HTML stehen. Desktop-, Mobil- und Drag-Screenshots visuell geprüft; Bericht unter `reports/editor/result.json`.
|
||||
@@ -247,7 +269,7 @@ Die Messung prüft die gemeinsame öffentliche Ausgabe nach Entfernung der feste
|
||||
- **154 PHP-Tests / 563 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **15 JavaScript-Modelltests**.
|
||||
- Ein v10-Dokument mit Palettenfarbe, eigenem Farbwert, Abständen je Seite, Ecken je Ecke, Schatten, Buchstabenabstand, Hover-Zustand und eigenem Mobilwert wird Byte für Byte gespeichert und zu blockgenauem CSS: Regel für den Block, eigene Regel für `:hover,:focus-within` und eine Media Query für die schmale Breite.
|
||||
- **Vierzehn abgewiesene Eingaben schreiben nichts.** Darunter gezielt die Ausbruchsversuche: eine Farbe als Funktion (`rgb(1,2,3)`), eine Länge als Ausdruck (`calc(1px + 2px)`), eine angehängte Deklaration (`18px;position:fixed`), ein Schriftschlüssel mit Anführungszeichen und ein Hintergrundbild als Dateipfad. Jeder dieser Werte scheitert am verankerten Muster seines Typs, bevor er gespeichert wird.
|
||||
- **Die Sicherheitsrichtlinie wurde eigens geprüft:** das erzeugte Stylesheet ist über seinen eigenen SHA-256-Hash erlaubt, `unsafe-inline` kommt im Kopf nicht vor, und Skripte bleiben auf Seiten ohne Tabs abgeschaltet. Ohne eingestellte Google-Schrift wird kein fremder Host erlaubt und keiner kontaktiert.
|
||||
- **Die Sicherheitsrichtlinie wurde eigens geprüft:** das erzeugte Stylesheet ist über seinen eigenen SHA-256-Hash erlaubt, `unsafe-inline` kommt im Kopf nicht vor, und Skripte bleiben auf Seiten ohne Tabs abgeschaltet. Ein fremder Schrifthost wird nie erlaubt und nie kontaktiert: Schriften liefert seit 1.14.0 ausschliesslich der eigene Server (ADR 096).
|
||||
- **Acht ungültige Themes** werden abgewiesen, ohne das gespeicherte Theme zu verändern; danach liefert das Theme weiterhin die Voreinstellungen.
|
||||
- Im Editormodell geprüft: Vererbung, einzelnes Zurücksetzen, Entfernen leerer Reste, Kopieren ohne geteilte Objekte und vollständiges Undo.
|
||||
- **Im Browser geprüft wurde nur der Aufbau, nicht die Bedienung.** Im laufenden Editor wurde über das DOM bestätigt, dass das Designpanel aus dem Serverschema entsteht: sieben Gruppen, 58 Felder, 11 Farbwähler, 12 Schieberegler, 34 Einheitenauswahlen. **Es wurde kein Gestaltungsvorgang von Hand ausgeführt**, keine Tastaturbedienung erprobt und keine axe- oder Lighthouse-Messung gemacht. Für ein Werkzeug dieser Größe ist das ein erheblicher Vorbehalt.
|
||||
|
||||
+884
-2
@@ -1,12 +1,894 @@
|
||||
# Projektstatus
|
||||
|
||||
## Fassung 1.28.1: weißer 500er bei IONOS durch die Umschreibregel (12.10.2026)
|
||||
|
||||
**Anlass:** huefner-gartenbau.de bei IONOS zeigte nach der Einrichtung auf jeder Adresse einen weißen 500er.
|
||||
|
||||
**Ursache und Änderung (ADR 114):** PHP 8.4.26 lief (`test.php`), `/index.php/install` lieferte 200, nur umgeschriebene Adressen scheiterten – Umschreibschleife in `public/.htaccess`. Neu im Kern: `RewriteBase /` und `RewriteRule ^index\.php$ - [L]` vor der `!-f`-Regel; Test in `CmsTest::testEveryPublicAnswerCarriesItsProtectiveHeaders`.
|
||||
|
||||
**Geprüft:** Umut hat beide Zeilen auf IONOS eingetragen, danach lädt `/install`; auch mit eingeschalteter `Options`-Zeile. PHPUnit vollständig: 643 Tests grün, eine Warnung aus `UpdaterTest` (fehlende `.env` im Testordner, unabhängig von dieser Änderung); php-cs-fixer sauber.
|
||||
|
||||
**Offen:** Der Installer auf huefner-gartenbau.de war bis zum Abschluss der Einrichtung öffentlich erreichbar.
|
||||
|
||||
**Nächster Schritt:** Bestehende IONOS-Installationen auf 1.28.1 aktualisieren.
|
||||
|
||||
## Fassung 1.28.0: Notfallseite, Fehlerprotokoll und Update mit einem Klick (09.10.2026)
|
||||
|
||||
**Anlass:** kantarci-reisen.de zeigt nach einer per FTP ersetzten Datenbank auf jeder Adresse eine leere 500-Seite. Umut: „Fang mit 1 und 2 an und verbessere den Update-Prozess … mach es wie bei reiseSoft.“
|
||||
|
||||
**Erledigt (ADR 113):** (1) Wartungsseite mit 503 statt leerem 500er, auch wenn Symfony nicht startet; (2) Fehlerprotokoll `var/log/notfall.log`, per FTP lesbar; (3) Update-Karte auf der Systemseite: sucht beim Öffnen selbst, zeigt die Release-Hinweise, ein Klick mit Bestätigung, Fortschritt in fünf Schritten live, Migration im selben Vorgang, bei Fehler automatisch zurück auf die alte Fassung samt Datenbank.
|
||||
|
||||
**Geprüft:** PHPUnit vollständig 643 grün (neu: `NotfallTest`, Fortschrittsdatei und vollständiges Zurückschalten in `ArchiveUpdateTest`, Endpunkte nur für Administratoren in `SystemScreenTest`), php-cs-fixer. Im Browser mit einer Installation aus dem Releasearchiv 1.27.1: erfolgreiches Update in einem Klick, Zurückschalten bei einer kaputten Testfassung.
|
||||
|
||||
**Veröffentlicht:** Tag `v1.28.0`, Release-Eintrag mit Archiv (3610 Dateien, 5,3 MB, gebaut aus `var/release-source-1.28.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
|
||||
|
||||
**Bekannt:** Der neue Ablauf greift erst bei Updates, die von 1.28.0 ausgehen. kantarci-reisen.de ist noch offline (Ursache unbekannt, Datenbank lokal mit 1.27.0 lauffähig).
|
||||
|
||||
**Nächster Schritt:** kantarci-reisen.de wiederherstellen (Notfallschicht hochladen → Protokoll lesen, oder Sicherung zurück) und aktualisieren.
|
||||
|
||||
## Fassung 1.27.1: Update in einem Zug, Kantarci Reisen auf Türkisch (09.10.2026)
|
||||
|
||||
**Anlass:** Umut: Update auf kantarci-reisen.de gab direkt danach einen 500er, nach „zurück“ ließ sich migrieren. „Können wir den Updater besser machen, dass alles automatisch abläuft, wie bei WordPress?“ Und: „Die DB hier komplett mit Übersetzung auf Türkisch fertig machen.“
|
||||
|
||||
**Updater (ADR 112):** Ein Klick spielt ein, die Anfrage endet nach dem Dateitausch mit einer schlichten Seite, die den Abschluss (Migrationen, Wartung aus) selbst aufruft – kein 500er, kein zweiter Knopf. Wirkt ab Updates, die von 1.27.1 ausgehen; 1.27.0 → 1.27.1 läuft noch den alten Weg.
|
||||
|
||||
**Türkisch im Kern vervollständigt:** feste Vorlagentexte und Formularmeldungen komplett auf Türkisch (vorher Teile auf Englisch). Zwei Sprachwahlen auf einer Seite heißen jetzt verschieden („Sprache“ im Kopf, „Sprache wählen“ sonst) – axe meldete sonst `landmark-unique`.
|
||||
|
||||
**Kantarci Reisen (nur Inhalt):** `scripts/translate-kantarci-reisen.php` schaltet Türkisch ein und übersetzt Startseite (67 Texte), Künye (`/tr/kunye`), Gizlilik (`/tr/gizlilik`), 404, Kopf, Fuß, Menü und das Anfrageformular; Anrede „siz“, Rechtstexte mit dem Hinweis, dass die deutsche Fassung gilt. Sprachwahl DE · TR im Kopf (auf dem Telefon ausgeblendet, kein Platz) und „Deutsch · Türkçe“ im Fuß. `reset-kantarci-reisen.sh` ruft das Skript am Ende auf. Ausgeführt auf `var/kantarci-reisen.db` (Sicherung `var/backups/vor-1.27.0/kantarci-reisen-vor-tr.db`).
|
||||
|
||||
**Korrigiert:** Die DM-Sans-600-Dateien von heute Nachmittag waren doch nötig – `FontLibrary::missing()` verlangt je Stärke eine Datei, sonst gibt die Website ihre eigenen Schriften ganz auf. Neu geholt (`cms:fonts:fetch`) und diesmal mit `fonts.css` committet. Live auf kantarci-reisen.de waren sie nie weg.
|
||||
|
||||
**Geprüft:** PHPUnit vollständig 637 grün (neu: Abschluss per Einmalschlüssel, Rolle, falscher Schlüssel, Neuladen), `npm test`, `lint:twig`, php-cs-fixer; Update-Ende-zu-Ende in einer Archivinstallation; `test:browser` auf Reisen (8104) grün; Lighthouse Produktion (`reports/lighthouse-kantarci-reisen-tr`): `/tr`, `/tr/gizlilik`, `/` überall 100, nur Leistung mobil 99 auf `/tr` und `/`. Telefonbreite ohne waagrechtes Scrollen.
|
||||
|
||||
**Veröffentlicht:** Tag `v1.27.1`, Release-Eintrag mit Archiv (3607 Dateien, 5,3 MB, gebaut aus `var/release-source-1.27.1`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Die lokalen Installationen laufen aus diesem Repository und haben 1.27.1 (keine Migration).
|
||||
|
||||
**Für den Livegang der türkischen Fassung:** erst kantarci-reisen.de auf 1.27.1 aktualisieren (sonst fehlen Teile der festen türkischen Texte), dann `var/kantarci-reisen.db` hochladen. Achtung: die hochgeladene Datei ersetzt die Datenbank dort – Einsendungen und Änderungen, die es nur live gibt, gehen dabei verloren.
|
||||
|
||||
## Fassung 1.27.0: Mehrsprachigkeit und übersichtlichere Formulare (09.10.2026)
|
||||
|
||||
**Inhalt:** Mehrsprachigkeit in drei Stufen (ADR 111, Schema v27, Migration `20261009000100`) und die neue Formularverwaltung – Einzelheiten in den drei Einträgen darunter.
|
||||
|
||||
**Geprüft vor dem Release:** PHPUnit vollständig auf dem Release-Stand 636 grün, `npm test` 41, `lint:twig`, php-cs-fixer, `test:admin --admin-only` und `test:editor` (mit axe) grün. Lighthouse im Produktionsmodus auf der Hüfner-Testkopie mit Englisch (`reports/lighthouse-sprachen`): `/en` mobil 97/100/100/100/100, Desktop 99/100/100/100/100; `/` mobil 96, Desktop 99, sonst 100.
|
||||
|
||||
**Veröffentlicht:** Tag `v1.27.0`, Release-Eintrag auf git.kant-it.de mit Archiv (3605 Dateien, 5,2 MB, gebaut aus `var/release-source-1.27.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt.
|
||||
|
||||
**Aufgeräumt:** Zwei nicht eingebundene DM-Sans-600-Dateien aus der Reisen-Installation gelöscht (die variable 400er-Datei deckt 400–700 ab), die nur umsortierte `fonts.css` auf den committeten Stand zurückgesetzt.
|
||||
|
||||
**Nächster Schritt:** Installationen auf 1.27.0 aktualisieren (Migration läuft über den Updater mit). Wer eine zweite Sprache will: Einstellungen → Sprachen, Sprachwahl in den Kopfbereich.
|
||||
|
||||
## Formularverwaltung übersichtlicher (09.10.2026)
|
||||
|
||||
**Anlass:** Umut: Formulare „sehr unübersichtlich … kompakter, nicht so wuchtig und so voll“; bei mehreren Formularen erst eine Übersicht ohne Vorschau, dann in die Seite rein.
|
||||
|
||||
**Neu:** `/admin/forms` ist eine Übersicht – je Formular eine Zeile mit Feldern, Empfänger, Adresse, übersetzten Sprachen und Zahl der Einsendungen, darunter die Einsendungen. Bearbeitet wird auf `/admin/forms/<adresse>` (neu: `/admin/forms/neu`), nach dem Speichern bleibt man dort. Im Editor ist jedes Feld eine Zeile (Beschriftung, Typ, Pflicht), alles Weitere unter „Details“ mit einer Kurzinfo darunter (technischer Name, Optionen, Hilfetext, Bedingung, „EN ✓“). Zustellung, Aussehen und je Sprache die Übersetzung sind zuklappbar mit Zusammenfassung; Erklärtexte gekürzt. Ohne JavaScript bleiben alle Felder sichtbar und bedienbar. Beim Testformular der Hüfner-Kopie: Seitenlänge von 4.913 px auf 1.274 px.
|
||||
|
||||
**Robuster:** Ohne weitere Sprache fasst der Kern die Tabelle `page_translation` nicht an – Speichern und Veröffentlichen funktionieren so auch, wenn nach einem Update die Migration noch aussteht (gefunden über die Pilot-Fixture, die ohne Migrationen kopiert).
|
||||
|
||||
**Geprüft:** PHPUnit vollständig 636 grün (vor dem Umbau), danach die betroffenen 59 (Editor-Sicherheit, Formulare, Übersetzung) grün; `npm test`, `lint:twig`, php-cs-fixer; `test:admin --admin-only` und `test:editor` grün inklusive axe auf allen Verwaltungsseiten und im Formulareditor; im Browser Übersicht, Bearbeiten, Speichern, Neu, 404 für unbekannte Adresse, Telefonbreite ohne waagrechtes Scrollen.
|
||||
|
||||
**Bekannt:** `test:admin` ohne `--admin-only` scheitert im Slider-Teil der Pilotseite – ebenso auf dem unveränderten Stand 1.26.0 (eigens in einem separaten Arbeitsverzeichnis geprüft), also kein Folgefehler dieser Änderungen.
|
||||
|
||||
## Mehrsprachigkeit, Stufe 3: Formulare, Beiträge, Einwilligung, 404 (09.10.2026)
|
||||
|
||||
**Neu (ADR 111, keine Migration):** Formulare je Sprache übersetzbar (in der Formularverwaltung am Feld: Beschriftung, Hilfetext, Optionen, Einheit; am Formular Button und Erfolgsmeldung), Fehlermeldungen und Schrittnavigation in der Sprache, Rückweg nach dem Absenden auf die übersetzte Seite; Einsendungen und E-Mail bleiben deutsch. Beitragslisten und Archive (`/en/beitraege`, `/en/kategorie/…`) mit übersetzten Titeln; Kategorie-/Schlagwortnamen und Einwilligungstext unter Einstellungen → Sprachen. 404 unter `/en/…` auf Englisch.
|
||||
|
||||
**Geprüft:** PHPUnit vollständig **636 grün** (neu: Formular in beiden Sprachen inkl. Absenden/Fehler/Weiterleitung; Beiträge, Archive, Kategorienamen, Einwilligung, 404), `npm test` 41, `lint:twig`, php-cs-fixer. Browser (Testkopie Hüfner): Formularübersetzung gespeichert, `/en` zeigt „Your name (EN) * (required)“ und „Send“, `/` unverändert. **Nicht geprüft:** Formularversand per E-Mail, Lighthouse/axe auf `/en`.
|
||||
|
||||
**Bekannt:** Feed, `/einwilligung`, Datumsformat und Archiv-Präfixe bleiben deutsch.
|
||||
|
||||
## Mehrsprachigkeit, Stufe 2: Kopf, Fuß, Menüs, feste Texte (09.10.2026)
|
||||
|
||||
**Anlass:** Umut: „Bilder lassen wir, mach weiter.“ Bilder bleiben für alle Sprachen gleich.
|
||||
|
||||
**Neu (ADR 111, keine weitere Migration):**
|
||||
|
||||
- **Kopf, Fuß und Sektionen** übersetzen: im Bereichs-Editor derselbe Umschalter DE | EN, die Liste zeigt nur die Texte (keine Adresse, kein Seitentitel). Gespeichert gilt sofort.
|
||||
- **Menüs:** je Eintrag ein Feld „Beschriftung Englisch“ (leer = deutsch). Auf `/en/…` zeigen die Einträge ihre englische Beschriftung und führen auf die englischen Seiten.
|
||||
- **Feste Texte** wie „Zum Inhalt springen“, „Menü“, „Hauptnavigation“, „Impressum/Datenschutz“ in der Rechtszeile, Einwilligungsknöpfe, „Weiterlesen“, „Nach oben“ erscheinen auf englischen Seiten englisch (Französisch/Türkisch teilweise, sonst Englisch).
|
||||
|
||||
**Geprüft:** PHPUnit vollständig **634 grün**, darunter neu ein Test für Kopfbereich, Menü und feste Texte in beiden Sprachen; `npm test` 41 grün; `lint:twig`; php-cs-fixer. Im Browser (Testkopie Hüfner, Port 8111): Menü mit „Services“/„References“ gespeichert, `/en` zeigt sie mit Zielen `/en#…`, „Skip to content“, „Main navigation“; Kopfbereich im Editor auf Englisch, „Request a quote“ in Vorschau und auf `/en`, `/` weiter „Angebot anfragen“. **Nicht geprüft:** Lighthouse/axe auf `/en`.
|
||||
|
||||
**Bekannt:** Formulare, Beiträge/Archive, Einwilligungstext aus den Einstellungen und die 404-Seite noch einsprachig. Nach einem Kern-Update cached die App-Vorschau alte Editor-Skripte (gleiche Fassungsnummer) – erst nach Neuladen der Assets sieht man Änderungen.
|
||||
|
||||
**Nächster Schritt:** Stufe 3 – Formulare (Felder, Meldungen, Bestätigungsmail) und Beiträge mit Archiven übersetzbar machen; danach Lighthouse/axe einer `/en`-Seite.
|
||||
|
||||
## Mehrsprachigkeit, Stufe 1: Seiten übersetzen statt neu bauen (09.10.2026)
|
||||
|
||||
**Anlass:** Umut: weitere Sprache hinzufügen und zu jedem Feld die 2. Sprache eintragen, ohne die Seite für die 2. Sprache neu zu bauen; Adressen mit `/en/`-Präfix und übersetzten Slugs.
|
||||
|
||||
**Neu (Kern, Schema v27, Migration `20261009000100`, ADR 111):**
|
||||
|
||||
- **Einstellungen → Sprachen:** Hauptsprache bleibt Deutsch, weitere Sprachen per Häkchen (10 zur Wahl, höchstens 5). Ohne Häkchen ist nirgends etwas zu sehen.
|
||||
- **Editor:** Umschalter DE | EN oben. In EN zeigt die Seitenleiste alle Texte der Seite mit Original, Zustand (fehlt / übersetzt / Original geändert), Filter, „Original übernehmen“, „Übersetzung passt“; dazu Adresse und Suchtitel. Vorschau in der Sprache, Klick springt zum Text. Aufbau und Gestaltung sind in EN gesperrt. Undo, Speichern und Veröffentlichen wie gewohnt – für alle Sprachen gemeinsam.
|
||||
- **Website:** `/en` und `/en/<übersetzte-adresse>`, `lang="en"`, Canonical, `hreflang` + `x-default`, `og:locale`, Sitemap. Links auf übersetzte Seiten führen zur Übersetzung. Nicht übersetzte Seiten: 404 unter `/en/…`. Neuer Baustein **„Sprachwahl“** (Kürzel oder Namen).
|
||||
|
||||
**Geprüft:** PHPUnit vollständig (633, ein Fehler durch die Änderung gefunden und behoben, danach die betroffenen 426 Tests erneut grün), neu `tests/TranslationTest.php` (Überlagerung, Prüfung wie Hauptsprache, Adressen beim Veröffentlichen/Verschieben mit Weiterleitung, Kollisionen, HTTP mit hreflang/Links/Sitemap/404). `npm test` 41 grün, neu `tests/translate.test.mjs` (Fingerabdruck gleich PHP, Feldliste gleich Server, Undo). `lint:twig`, php-cs-fixer. Im Browser auf einer Kopie der Hüfner-Datenbank (`var/browser-tests/lang/`, Startkonfiguration `sprachen-test`, Port 8111): Sprache eingeschaltet, Startseite übersetzt, Undo/Redo, Speichern, Veröffentlichen, `/en` mit englischem Titel und `hreflang`, `/en/impressum` 404, Sitemap mit `/en`. **Nicht geprüft:** Lighthouse und axe einer `/en`-Seite und des Bausteins „Sprachwahl“, Tastaturfokus beim Klick in die Vorschau (das Browserfenster der Prüfung hatte keinen Fokus).
|
||||
|
||||
**Bekannt (Stufe 2):** Kopf/Fuß, Menübeschriftungen, Formulare, Beiträge/Archive, Einwilligung, 404 und feste Vorlagentexte bleiben auf englischen Seiten deutsch. Eigenes HTML und Plugin-Bausteine unübersetzbar, Bilder nicht je Sprache. Ändert der Server beim Speichern formatierten Text der Hauptsprache, können Übersetzungen fälschlich als „Original geändert“ erscheinen. Dokumente mit Übersetzungen oder Sprachwahl brauchen einen Kern mit Schema v27.
|
||||
|
||||
**Nächster Schritt:** Stufe 2 – Menüs und Kopf-/Fußbereiche übersetzen, danach Formulare und Beiträge. Vorher mit Umut klären, ob Bilder je Sprache austauschbar sein sollen.
|
||||
|
||||
## Zehnte Installation „Kantarci Reisen“ (08.10.2026)
|
||||
|
||||
**Anlass:** Umut: neue Website nach dem Mockup `Downloads/Kantarci-Reisen-Komplettpaket.zip`, DB und Medienordner nach gleichem Muster, Inhalte dürfen individualisiert werden. Inhaber I. Kantarci, Ooser Hauptstraße 51, 76532 Baden-Baden, info@kantarci-reisen.de. Für reiseSoft ein echter Screenshot aus `Downloads/reiseSoft`, Plattform unter reisesoft.kantarci-reisen.de.
|
||||
|
||||
**Neu (nur Inhalt, kein Kerncode):** Datenbank `var/kantarci-reisen.db`, Medien `var/kantarci-reisen-media`, Bildquellen `var/kantarci-reisen-assets`, Startkonfigurationen `kantarci-reisen` (Port 8104, dev) und `kantarci-reisen-prod` (8105). `sh scripts/reset-kantarci-reisen.sh <Ordner Kantarci-Reisen> <Screenshot.png>` schneidet Logo und Seitensymbol zu (`build-kantarci-reisen-assets.php`), baut mit `seed-kantarci-reisen.php` alles neu und holt DM Sans auf den eigenen Server. Ohne Argumente nimmt es die vorbereiteten Bilder.
|
||||
|
||||
- **Startseite** wie das Mockup: geteilter Hero (Petrol/Küstenfoto), drei Angebote als ganz klickbare Karten (ohne Nummern, auf Umuts Wunsch), „Gute Reisen beginnen mit einem Gespräch“ mit Anschrift, neu „So läuft’s“ in drei Schritten, Anfrage auf Petrol mit CMS-Formular `reiseanfrage` (Versand an info@kantarci-reisen.de, dazu Telefon, Reisezeitraum, Personen), reiseSoft mit echtem Screenshot im Browserrahmen und Knopf zur Plattform. Dazu Impressum (Reisevermittlung), Datenschutz, 404, klebender Kopf, Einblendungen.
|
||||
- **„Reise anfragen“** ist ein Menü-Button (ADR 110) mit Pfeil, auf dem Telefon neben dem Menü-Knopf. Eigenes Menü-CSS darf `.menu-list a` kein Polster geben, ohne es dem Button zurückzugeben (`.menu-list .menu-button`).
|
||||
- **Screenshot reiseSoft:** gebaute Fassung von reiseSoft lokal (Port 4199) mit leerem Datenordner im Scratchpad gestartet, Ersteinrichtung mit Testzugang, `scripts/demodaten.mjs` (60 fiktive Vorgänge), Übersicht in 1440×900 @2x aufgenommen. Keine echten Kundendaten.
|
||||
|
||||
**Geprüft:** `test:browser` auf 8104 grün (HTML 66 KB). Lighthouse auf 8105 (Produktion): `/` mobil 98/100/100/100/100, Desktop 100 überall; `/impressum` überall 100 (`reports/lighthouse-kantarci-reisen`). Ganzseitige Screenshots 1440 und 390 px ohne Konsolenfehler und ohne waagrechtes Scrollen. Nicht geprüft: Formularversand per E-Mail.
|
||||
|
||||
**Nachtrag (08.10.2026):** Telefon 0170 2945935 (auch WhatsApp) von Umut; eine USt-IdNr. gibt es nicht. WhatsApp als Anfrageweg aus dem Baustein „Soziale Profile“ (Netzwerk in den Einstellungen, Link mit vorausgefülltem Text): Knopf unter „Über uns“ und neben dem Formular, Link im Fuß, runde Schaltfläche fest unten links (`position:fixed` im eigenen CSS). Datenschutz mit eigenem Punkt zu WhatsApp. „Inhaber“ steht nicht mehr im Fuß, nur im Impressum. Danach erneut `test:browser` grün (HTML 72 KB) und Lighthouse `/` mobil 98, sonst 100; `/datenschutz` überall 100.
|
||||
|
||||
**Bekannt:** Öffnungszeiten und Hoster sind unbekannt und stehen nirgends (Datenschutz nennt den Hoster allgemein). **Die Domain kantarci-reisen.de existiert am 08.10.2026 im DNS nicht** (NXDOMAIN bei Fritz.box, 1.1.1.1 und 8.8.8.8) – damit kommen weder Mails an info@kantarci-reisen.de an noch ist reisesoft.kantarci-reisen.de erreichbar. Vor dem Livegang klären. Das Reisefoto ist laut Paket KI-generiert, so im Bildnachweis. Grenzen des Kerns beim Aufbau: Textblöcke geben `<span>` nicht aus, eigenes CSS erlaubt keine Anführungszeichen in Werten, kein `calc()` in `maxWidth`, kein `>*` und keine `?` in Attributselektoren.
|
||||
|
||||
**Nächster Schritt:** Inhalte mit Umut abstimmen, dann Livegang nach `docs/LIVEGANG.md`.
|
||||
|
||||
## Fassung 1.26.0: Menüeinträge als Button (07.10.2026)
|
||||
|
||||
**Anlass:** Umut: Buttons im Menü – individuell wie im Editor, mit Symbol und Farbe, auf dem Telefon im aufgeklappten Menü oder neben dem Menü-Knopf.
|
||||
|
||||
**Neu (ADR 110):** In der Menüverwaltung je Eintrag „Als Button darstellen“ mit Form, Darstellung, Größe, Fläche/Schrift (auch beim Überfahren), Symbolfarbe, Symbol aus dem Fenster, Bewegung, „nur Symbol“ und Anzeige auf dem Telefon (im Menü / neben dem Menü-Knopf / dort nur als Symbol). Vorschau normal und beim Überfahren.
|
||||
|
||||
**Geprüft:** PHPUnit vollständig, `npm test`, `npm run test:menu-buttons` (Browser, axe, 1440 und 390 px, je ein Button neben dem Menü-Knopf und im aufgeklappten Menü), `lint:twig`, php-cs-fixer.
|
||||
|
||||
**Bekannt:** In schmalen Kopfspalten bricht der Button in die nächste Zeile (wie jeder Menüeintrag). Kern ab 1.26.0 nötig.
|
||||
|
||||
## Fassung 1.25.0: 98 Symbole, Auswahlfenster, Symbol-Gestaltung unter Design (07.10.2026)
|
||||
|
||||
**Anlass:** Umut zu 1.24.0: „Du wolltest doch mehr Icons machen oder nicht? Und nicht alles so dort auflisten, erst onklick einblenden oder Modal das gefällt mir nicht. Dann brauche ich Icon-Einstellungen ausgefüllt, extra Farbe usw. Mach es doch durchdacht.“
|
||||
|
||||
**Neu (ADR 109):**
|
||||
|
||||
- **98 Symbole** statt 33 in sieben Kategorien, darunter Warenkorb, Euro, LKW, Paket, Karte, Kamera, Schloss, Werkzeug, Malerrolle, Baum, Solar, Strom, Auto, Warnung. Quelle `public/assets/icons.js`, `npm run build:icons` schreibt Twig und Validator.
|
||||
- **Auswahlfenster:** Im Panel nur das gewählte Symbol mit „Ändern …“. Das Fenster zeigt Kategorien als Reiter und Symbole mit Namen. Gilt für Button, Symbol und Symbolkarte.
|
||||
- **Design → Symbol** (je Gerät, mit Hover): Farbe, Fläche dahinter, Größe, Innenabstand und Ecken der Fläche, Drehung, Abstand zum Text.
|
||||
- **Inhalt (Schema v26):** Stellung, „Beim Überfahren: Ruhig / Rückt / Erscheint“, „Nur das Symbol zeigen“ (quadratischer Knopf, Beschriftung bleibt für Vorlesesoftware).
|
||||
|
||||
**Geprüft:** PHPUnit vollständig (616) grün, `npm test` (38) grün, `npm run test:button-icons` (Editor im Browser, inkl. axe im Fenster und gemessenem Aussehen), `test:buttons` grün, `test:browser` auf Hüfner (Entwicklungsmodus, Port 8102) grün mit HTML 148 KB. php-cs-fixer und `lint:twig` ohne Befund. Nicht geprüft: Lighthouse.
|
||||
|
||||
**Bekannt:** Dokumente mit Bewegung oder „nur Symbol“ brauchen einen Kern ab 1.25.0 (Schema v26). Die Gestaltungswerte (Farbe usw.) brauchen kein neues Schema, werden von älteren Kernen aber abgelehnt (unbekannte Stileigenschaft) – Installationen zuerst aktualisieren. `test:browser` verlangt den Entwicklungsmodus (Kopf `x-kant-queries`), nicht wie in der README beschrieben den Produktionsmodus.
|
||||
|
||||
**Veröffentlicht:** Release v1.25.0 auf git.kant-it.de mit Archiv und `install.php`, Prüfsumme nach dem Herunterladen bestätigt.
|
||||
|
||||
**Hüfner aktualisiert (07.10.2026):** Die lokale Installation läuft aus diesem Repository und hat damit 1.25.0. Auf der Startseite ist das LKW-Symbol der Vorteilsleiste (`vorteil-3-icon`) jetzt der Baustein „Symbol“ mit `truck`, gestaltet wie die drei Nachbarn (32 px, `@hellgruen`), statt „Eigenes HTML“. Geändert direkt in `var/huefner.db` (veröffentlicht, Sicherung `var/huefner.db.vor-lkw`) und in `scripts/seed-huefner.php`, damit ein Neuaufbau dasselbe ergibt. Geprüft im Browser (Symbol sichtbar, keine HTML-Bausteine mehr) und mit `test:browser` (grün, HTML 148 KB).
|
||||
|
||||
**Nächster Schritt:** Livegang Hüfner nach `docs/LIVEGANG.md`, die übrigen Installationen auf 1.25.0 aktualisieren.
|
||||
|
||||
## Fassung 1.24.0: Symbol am Button wählbar, Kachelauswahl für Symbole (07.10.2026)
|
||||
|
||||
**Anlass:** Umut: „Buttons mit Icon? Geht das, wenn ja wo? Und einfacher Button ist ein Pfeil, wie ändere ich das?“ – dann: „Ja bitte umsetzen. Ein Satz Symbole, die man so alltäglich brauchen könnte, und schöne Vorschau-Auswahl, bitte kein Dropdown mit Suche usw.“
|
||||
|
||||
**Neu (Kern, Schema v25, ADR 108):**
|
||||
|
||||
- Button → Inhalt → **Symbol**: Kacheln mit Vorschau in drei Gruppen (Pfeile, Kontakt, Allgemein), „Kein Symbol“ vorn. Darunter **Symbol steht: Vor dem Text / Nach dem Text**, nur wenn ein Symbol gewählt ist. Ohne Wahl bleibt der schräge Pfeil hinter dem Text – bestehende Seiten sehen unverändert aus, eigenes CSS auf `.button-arrow` greift weiter.
|
||||
- 13 neue Symbole im gemeinsamen Satz (Pfeil nach unten, Winkel, Kalender, Nachricht, Senden, Download, Dokument, Abspielen, Suche, Herz, Info, Plus, Person), damit 33 insgesamt.
|
||||
- Symbol und Symbolkarte wählen ihr Symbol mit derselben Kachelauswahl statt der Liste mit englischen Namen.
|
||||
- `public/assets/icons.js` (Pfade, deutsche Namen, Gruppen, Auswahl), in der Importmap eingetragen. Stile in `button-controls.css`. `site.css`: Hülle heißt `button-icon`.
|
||||
|
||||
**Geprüft:** PHPUnit vollständig (609 Tests) grün, darunter zwei neue Tests in `CmsTest` (Ausgabe mit Standardpfeil, Telefon vorn, kein Symbol; Ablehnung unbekannter Symbole und älterer Schemata). `npm test` (38) grün, dazu `tests/icons.test.mjs` (Twig, Editor und Validator kennen dieselben Symbole) und `repairSchema` hebt auf v25. `npm run test:button-icons` im Browser auf einer Kopie der Pilotdaten: Kachel wählen, Stellung umschalten, Pfeiltasten und Enter, Rückgängig/Wiederholen, kein Symbol, Speichern als v25, nach Neuladen gleich; keine Seitenfehler. Screenshots `reports/button-icons/`. php-cs-fixer und `lint:twig` ohne Befund. Nicht geprüft: Lighthouse und `test:browser` (die öffentliche Ausgabe ändert sich nur bei gewähltem Symbol um ein Inline-SVG gleicher Größe).
|
||||
|
||||
**Bekannt:** Ein Dokument mit Symbolwahl lässt sich auf einem Kern vor dieser Fassung nicht speichern (Schema v25) – Installationen zuerst aktualisieren. Keine Markenlogos im Satz.
|
||||
|
||||
**Nächster Schritt:** Push von `main` und Tag `v1.24.0`, Releasearchiv auf der Releaseseite veröffentlichen, dann die Installationen aktualisieren (vor Hüfner, damit dort Symbole gewählt werden können).
|
||||
|
||||
## Fassung 1.23.1: neunte Installation „Hüfner Gartenbau“, Knöpfe mit tel: und mailto:, Pfeil in der Schriftfarbe (06.10.2026)
|
||||
|
||||
**Anlass:** Umut: „Hier liegt ein Designentwurf für Hüfner Gartenbau. Kannst du mir db und media erstellen für KantSites bitte. […] Effekte rein. Nichts am Code ändern, sondern alles an KantSites-Designelementen anpassen/bauen. Falls was nicht geht, Rückmeldung.“ Vorlage: `Downloads/huefner-website/huefner-website` (statisches `index.html`, 16 Fotos, Logo).
|
||||
|
||||
**Neu (nur Inhalt, kein Kerncode):** Datenbank `var/huefner.db`, Medien `var/huefner-media`, Bildquellen `var/huefner-assets`, Startkonfigurationen `huefner` (Port 8102, dev) und `huefner-prod` (Port 8103). `sh scripts/reset-huefner.sh <Ordner des Entwurfs>` kopiert die Bilder und schneidet den Baum aus dem Logo als Seitensymbol (`build-huefner-assets.php`), baut mit `seed-huefner.php` alles neu und holt Barlow und Barlow Condensed auf den eigenen Server. Ohne Argument nimmt es die schon vorbereiteten Bilder.
|
||||
|
||||
- **Startseite** mit allen elf Abschnitten des Entwurfs: Hero mit Verlauf und Vorteilsband, Collage, Leistungs- und Referenzkacheln (Foto zoomt, Kachel bekommt Schatten), Chips, Ablauf mit gestrichelter Linie, Fuhrpark, Über uns, Karriere (Stellen als Links mit E-Mail-Betreff), Instagram-Raster, Abschlussband, Kontakt mit dem CMS-Formular `anfrage` (Versand an info@huefner-gartenbau.de). Dazu Impressum, Datenschutz, 404, Menü mit Sprungmarken, klebender Kopf, Einblendungen beim Scrollen.
|
||||
- **Inhaltliche Ergänzungen** statt der Platzhalter der Vorlage: Öffnungszeiten Mo–Fr 8–17, Sa 8–12 (von Umut, auch als strukturierte Daten), Firmengeschichte und Impressum aus dem Handelsregister (HRB 742159, AG Mannheim, GF Florian Hüfner; Simone Hüfner war nur bis 27.04.2026 Prokuristin), Hoster IONOS, alle Fotos eigene Aufnahmen. Kennzahlen „[XX] Jahre“/„[XXX] Projekte“ ersetzt durch Aussagen der Vorlage (100 % Detailarbeit, Bagger bis 15 t, ein Team). Keine USt-IdNr. bekannt, deshalb keine Zeile.
|
||||
|
||||
**Grenzen des Kerns:**
|
||||
|
||||
- Nachtrag, gleicher Tag – auf Umuts Wunsch im Kern behoben (ADR 107): Knöpfe und andere verlinkbare Bausteine nehmen `tel:` und `mailto:` an, und der Pfeil im Knopf hat die Schriftfarbe statt der Akzentfarbe. Die Anrufknöpfe bei Hüfner sind jetzt echte Knöpfe, der Pfeil braucht keine eigene Regel mehr. Vorher: Textlink im Knopf-Aussehen und `color:inherit` im CSS des Knopfs.
|
||||
- Kein LKW im Symbolsatz: Das Symbol steht als „Eigenes HTML“ mit dem SVG der Vorlage.
|
||||
- Leiste und Kopf kleben gemeinsam (die Einstellung gilt für den ganzen Kopfbereich).
|
||||
- Zeilen sind durchsichtig, farbige Karten deshalb Spalten mit Flex.
|
||||
- Eigenes CSS ohne `@media`/`*`: gerätespezifische Werte über die Gestaltungswerte (Bildhöhe, Raster-Spalten, Ränder je Gerät).
|
||||
- Das HTML-Budget von 150 KB (`test:browser`) war mit dem CSS je Kachel überschritten (152 KB). Die gemeinsamen Regeln stehen jetzt einmal am Abschnitt, Ergebnis 147,5 KB.
|
||||
|
||||
**Geprüft:** Lighthouse im Produktionsmodus (Port 8103, je drei Läufe, Median): Startseite mobil 97 / Desktop 98, Impressum und Datenschutz 100 / 100; Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100. `test:links` ohne toten Verweis, `test:browser` grün (axe in sechs Ansichten, Tastatur, ohne JavaScript, Budget). Ganzseitige Bildschirmfotos bei 1440, 820 und 375 px gegen den Entwurf verglichen, kein waagerechtes Scrollen, keine Konsolenfehler. Zoom, Schatten, Chip- und Knopf-Hover sowie das Klappmenü per Playwright nachgemessen. Keine Felddaten, kein INP. Das Formular wurde nicht abgeschickt (Mailversand nicht ausprobiert).
|
||||
|
||||
**Nächster Schritt:** Livegang nach `docs/LIVEGANG.md` (Mailversand einmal echt testen). Die Hüfner-Datenbank braucht einen Kern mit ADR 107 (`tel:` an Knöpfen).
|
||||
|
||||
## Kantarci Markt: Demo der Eröffnungsseite mit Angeboten aus der App (24.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Setze mal die demoseite um wie du es dir vorstellst ich überlasse dir alles. Links und logo zu playstore und ios app, wilhelm drapp str. in baden baden, Rest Platzhalter“. Die Angebote kommen aus der Web-Ansicht seiner App: `https://app.kantarci-markt.de/embed/angebote.php?ton=dunkel`, dazu sein Skript für die Höhe.
|
||||
|
||||
**Neu (nur Inhalt, kein Kern):** `/eroeffnung` im Seed-Skript, veröffentlicht, aber `noindex` und nirgends verlinkt. Aufbau: Einstieg „Jetzt geöffnet“ mit zwei Wegen (Angebote, Anfahrt) und Öffnungszeiten, Angebote als eingebettete App-Ansicht, App-Bereich mit Knöpfen für App Store und Google Play, das Versprechen, Besuch (Adresse mit „Route planen“, Öffnungszeiten, Kontakt), Abschluss. Zur Eröffnung soll dieser Inhalt als geplanter Entwurf auf die Startseite, `/eroeffnung` fällt dann weg.
|
||||
|
||||
- **Einwilligung:** keine nötig. `Consent::granted()` lässt jede Gruppe durch, solange die Website keine Einwilligungsleiste hat (bei Kantarci Markt aus). Meine frühere Aussage, der Baustein verlange immer eine Einwilligung, war falsch. Eine Option „Eigener Dienst“ braucht es erst, wenn die Seite einmal eine Leiste bekommt.
|
||||
- **Höhe:** Umuts Skript hängt als eigenes Skript am Baustein. Es lief zu spät: Die App meldet ihre Höhe beim Laden (gemessen 332 ms), das Skript lief als `defer`-Datei erst kurz vor DOMContentLoaded (402 ms), danach meldet die App nur noch Änderungen. Jetzt fragt das Skript nach dem Start per `km-angebote-height-request` nach und lädt den Rahmen als Rückfall nach 600 ms einmal neu, falls keine Meldung kam. Die App kennt die Nachfrage noch nicht, deshalb greift derzeit der Rückfall (zweiter Abruf der Web-Ansicht).
|
||||
- **Hintergrund:** Die Web-Ansicht ist für hellen Grund gebaut (dunkle Überschrift, weiße Karten), `ton=dunkel` und `ton=hell` liefern dieselbe Seite. Der Angebotsbereich steht deshalb auf Sand statt auf Waldgrün. `& iframe{border-radius:0;background:transparent}` am Baustein nimmt Rundung und Tönung aus `site.css`.
|
||||
- **Platzhalter:** Öffnungszeiten (Mo–Sa 8–20 Uhr, So geschlossen), „Telefon folgt“, Store-Links auf die Suche nach „Kantarci Markt“ im jeweiligen Store, Store-Knöpfe als eigene schwarze Knöpfe ohne die Logos von Apple und Google. Alles oben im Abschnitt des Seed-Skripts.
|
||||
- **`scripts/audit.mjs`** kennt `CMS_HOST_RULES` (etwa `MAP localhost 127.0.0.1`): Die App erlaubt das Einbetten nur von `kantarci-markt.de` und `http://localhost:*`, der Server lauscht auf 127.0.0.1.
|
||||
|
||||
**Geprüft:** Lighthouse im Produktionsmodus über `localhost:8101`, Rahmen geladen: `/eroeffnung` mobil 96 / Desktop 100, Barrierefreiheit, Best Practices und agentisches Browsing 100. SEO 69 nur wegen des gewollten `noindex` (`is-crawlable`), keine Konsolenfehler. Die Startseite blieb bei 98 / 100 und überall sonst 100. Im Browser: Rahmen 448 px (Desktop) bzw. 971 px (375 px) hoch, kein Scrollbalken, kein waagerechtes Scrollen. Sicherheitsrichtlinie der Seite: `frame-src https://app.kantarci-markt.de`, `script-src 'self'`.
|
||||
|
||||
**Nachtrag, gleicher Tag – Popup statt Link (App-Repository `C:\Django Project\Supermarkt`):** Umut: Angebote ohne Bild wirkten durchsichtig, „Unsere Angebote“ war weiß, und statt eines Links soll ein Popup die Einzelheiten hier zeigen. Die App-Ansicht hatte inzwischen `ton`, `titel`, `app`, `bg` bekommen; `ton=dunkel` machte die Überschrift weiß. Eingebunden ist sie jetzt mit `?titel=0&app=0` (eigene Überschrift der Seite, kein Knopf nach außen). In der App (`public/embed/angebote.php`, noch nicht committet, nicht ausgeliefert): Karten sind `<article>` mit einem Knopf im Namen statt `<a>`, ein natives `<dialog>` zeigt Bild, Kategorie, Name, Gültigkeit, Beschreibung und Preise auf Höhe der Karte (das iframe ist so hoch wie sein Inhalt) und meldet per `km-angebote-focus`, wo es steht. Die Ansicht beantwortet `km-angebote-height-request`, Angebote ohne Bild haben zartes Grün statt Creme, und ohne Datenbank gibt es die leere Ansicht statt eines Fatal Errors (`count(null)`). `embed/angebote.js` und das Skript im Seed kennen Nachfrage und Hinscrollen. Geprüft mit der lokalen App-Fassung (Anfragen im Browser umgeleitet, Beispieldaten im lokalen Cache): keine Links mehr, Popup im sichtbaren Bereich (auch nach Scrollen), Fokus auf „Schließen“ und zurück zur Karte, Esc und Klick auf den Rand schließen, axe ohne Befund, nur ein Abruf der Ansicht (kein Rückfall-Neuladen). Live wirkt es erst nach Push und App-Update auf dem Server.
|
||||
|
||||
**Offen, von Umut:** echte Store-Adressen (und auf Wunsch die offiziellen Badges), Öffnungszeiten, Telefon, Eröffnungsdatum, Impressumsangaben. App-Änderungen committen, pushen und auf dem Server aktualisieren.
|
||||
|
||||
## Fassung 1.23.0: achte Installation „Kantarci Markt – Neueröffnung“, content im eigenen CSS, Schriften ergänzt statt überschrieben (23.09.2026)
|
||||
|
||||
**Anlass:** Umut: „erstellst du bitte bald verfügbar site nach dem prinzip kannst gerne dazu implementieren“ – dazu das Paket `Kantarci-Markt-Landingpage.zip` (statische Seite: Logo-Kopf, Hero „Frische bekommt eine neue Adresse.“ mit Marktfoto, Tomatenkarte und Stempel, Versprechen auf Waldgrün, Abschluss „Frische. Qualität. Faire Preise.“, Fuß).
|
||||
|
||||
**Neu.** Datenbank `var/kantarci-markt.db`, Medien `var/kantarci-markt-media`, Bildquellen `var/kantarci-markt-assets`, Startkonfigurationen `kantarci-markt` (Port 8100, dev) und `kantarci-markt-prod` (Port 8101). `sh scripts/reset-kantarci-markt.sh` baut alles neu: `build-kantarci-markt-assets.mjs` (Logo zugeschnitten auf 600 px, Seitensymbol und Stempel als PNG über Chromium), `seed-kantarci-markt.php` (Startseite, Impressum, Datenschutz, 404, Kopf, Fuß, Einstellungen), dann DM Sans auf den eigenen Server. Alles aus gewöhnlichen Bausteinen, kein Plugin.
|
||||
|
||||
- **Abweichungen:** Manrope entfällt (DM Sans an seiner Stelle, eine Schrift weniger). Die Bildzeile „Vorfreude auf frische Vielfalt.“ steht höher als in der Vorlage, weil Stempel und Tomatenkarte sie dort verdeckten. Das große „K“ im Abschluss ist ein Pseudo-Element, kein Text. Keine LocalBusiness-Daten, solange der Markt nicht geöffnet hat.
|
||||
- **Impressum und Datenschutz** sind Pflicht und angelegt. Offen, im Text als „NOCH ZU PRÜFEN“: Betreiber/Rechtsform, Hausnummer, Vertretung, Register, Telefon, USt-IdNr., ob `info@kantarci-markt.de` existiert (die Domain hat Mailserver), Quelle der beiden Fotos, Hosting.
|
||||
|
||||
**Im Kern dazu gebaut:**
|
||||
|
||||
- **Eigenes CSS: `content` mit kurzem Text** (`CustomCode`), damit `::before`/`::after` möglich sind. Eng begrenzt, siehe Nachtrag zu ADR 028.
|
||||
- **`fonts.css` wird ergänzt statt überschrieben** (`FontLibrary::combine`): Ein Abruf ersetzt nur die Regeln der geholten Familien. Vorher hätte das Holen von DM Sans Lato und Cormorant der anderen lokalen Installationen aus der gemeinsamen Datei gelöscht.
|
||||
- **Variable Schriften einmal statt je Schnitt:** DM Sans liefert Google als eine Datei für alle Schnitte. Der Kern verlinkte und lud sie dreimal vor (111 KB statt 37 KB), das kostete am Telefon den LCP. Jetzt eine `@font-face` mit Gewichtsbereich `400 700`, und `preload()` nennt nur Dateien, die `fonts.css` tatsächlich verlinkt.
|
||||
- Bekannt, nicht geändert: Zeilen sind im Kern immer durchsichtig (`.block-row{background:transparent!important}`). Die grüne Karte „Wir öffnen bald“ ist deshalb eine Spalte.
|
||||
|
||||
**Geprüft:** Lighthouse im Produktionsmodus (Port 8101, je drei Läufe, Median): Startseite mobil 97 / Desktop 100, Impressum und Datenschutz 100 / 100; Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100. Vorher 87 mobil wegen CLS 0,19 (Logo im Kopf bekam seine Breite erst nach dem Laden) und der dreifachen Schrift. Bildschirmfotos 1440 px und 375 px ohne waagerechtes Scrollen und ohne Konsolenfehler, Impressum und 404 angesehen. Neue Tests in `CmsTest` (`content` erlaubt/abgewiesen, `combine`, Preload bei variabler Schrift); volle PHPUnit-Suite (596) und `npm test` (36) grün. Keine Felddaten, kein INP.
|
||||
|
||||
**Nächster Schritt:** Die offenen Angaben im Impressum von Umut bekommen; auf Wunsch ein Formular „Zur Eröffnung benachrichtigen“ (braucht dann Einwilligungstext und Datenschutzabsatz).
|
||||
|
||||
## Fassung 1.22.5: alte Browserchecks wieder grün, Kopfbereich von kant-it.de wieder bearbeitbar (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „räum den Strukturbaum-Fehler auch auf und Release 1.22.5“.
|
||||
|
||||
- **Strukturbaum:** `test:admin`, `test:buttons` und `test:navigation-ux` klickten direkt in den Strukturbaum. Seit 1.11 zeigt die Seitenleiste aber eine Ansicht auf einmal, Standard „Elemente“, und der Baum war versteckt. Jetzt wählen die Checks den Reiter „Struktur“, dann den Baustein und „Bearbeiten“, wie `editor-check.mjs`. Der Editor selbst war richtig.
|
||||
- **Echter Fehler, dabei gefunden:** Der Kopfbereich in `var/kantit.db` (und damit vermutlich auf kant-it.de) trägt die Hamburger-Umschaltung (`collapse`) bei Schema v19, erlaubt ist sie erst ab v22. Die Website zeigt ihn, aber der Editor bekam für die Vorschau „Unbekannte Hamburger-Umschaltung oder Dokumentschema vor v22“, und Speichern wäre ebenso abgelehnt worden: Der Bereich war nicht bearbeitbar. Jetzt hebt `repairSchema()` (editor-model.js) ein geladenes Dokument auf die Fassung, die seine Bausteine und Eigenschaften verlangen. Das ist dieselbe Hebung, die der Editor beim Einstellen ohnehin macht. Die Umstellung auf formatierten Text (v12/v17) kommt nie von selbst, weil sie die Bedeutung ändert. Beim nächsten Speichern ist das Dokument korrigiert. Alle anderen lokalen Installationen hatten keine solchen Dokumente (geprüft mit dem Validator über alle Seiten und Bereiche).
|
||||
- **Weitere veraltete Stellen in `test:admin`:** Der Knopf „Bewegung testen“ heißt seit 1.11 „Bewegung in der Vorschau abspielen“ (`#preview-motion`), auch ein Hilfetext im Editor nannte noch den alten Namen. Der Slider-Teil erwartete, dass „Bewegung reduzieren“ die Automatik anhält. Seit 1.11 gilt das nur mit „Systemvorgabe beachten“. Der Check prüft jetzt das Verhalten, das die Einstellung der Website verspricht.
|
||||
|
||||
**Geprüft:** `test:admin` (vollständig, auch der öffentliche Teil), `test:buttons`, `test:navigation-ux` und `test:editor` grün. Neuer Einheitstest für `repairSchema` in `tests/editor-model.test.mjs`.
|
||||
|
||||
## Fassung 1.22.4: Ziehknopf im Menü neben der Zusammenfassung (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „ja räum das auf und Release 1.22.4“ – die bekannte axe-Meldung `nested-interactive` im Menü-Editor.
|
||||
|
||||
- Der Ziehknopf eines Menüeintrags stand in der `<summary>`, also ein Knopf in einem Knopf. Screenreader lesen so etwas unzuverlässig vor, und die Tastatur erreicht es nicht sauber. Jetzt steht er als Geschwister vor `<details>`, per CSS über die linke Ecke der Zusammenfassung gelegt (`menu-entry.html.twig`, `createRow` in `menu-editor.js`). Aussehen und Bedienung bleiben gleich: Ziehen mit Maus, Stift und Finger, Pfeiltasten auf dem Knopf.
|
||||
- Der Hinweis zu fehlenden Sprungzielen im Menü und die Hilfe zum Feld „Sprungziel“ im Editor nennen jetzt die richtige Stelle (Sektion, Zeile oder Spalte) und den Weg über die Auswahl im Menü.
|
||||
- `test:admin`: Die Fehlermeldung nach einem abgewiesenen Speichern wurde direkt nach der Serverantwort abgefragt, sie erscheint aber erst, nachdem die Antwort gelesen ist. Jetzt wartet der Test auf sie. Der Check scheitert weiterhin an der älteren Stelle beim Klick in den Strukturbaum des Editors.
|
||||
|
||||
**Geprüft:** Die Editor-Prüfung prüft die ganze Menüseite mit axe, ohne Ausnahme. Sie stellt sicher, dass in keiner Zusammenfassung ein Knopf steht, und verschiebt einen Eintrag per Pfeiltaste (Fokus bleibt auf dem Knopf). Bildschirmfoto `reports/editor/menu-editor.png` angesehen.
|
||||
|
||||
## Fassung 1.22.3: die übrigen abgetippten Adressen (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „die offenen Punkte auch umsetzen und Release 1.22.3“ – die drei Reste aus 1.22.2.
|
||||
|
||||
- **Sprungziel im Menü:** Eine Auswahl aus den Ankern der verknüpften Seite, beschriftet mit der ersten Überschrift des Bausteins, etwa „So erreichen Sie uns (#kontakt)“. Die Liste liefert `GET /admin/api/sprungziele/{id}` (Rolle wie die Menüs). Wechselt die Seite, lädt die Liste neu. Ein gespeicherter Anker, den es nicht mehr gibt, bleibt als „(auf der Seite nicht gefunden)“ sichtbar. Ohne Skript bleibt das Textfeld.
|
||||
- **Linkfelder in der Plugin-Verwaltung und die Adresse eines eigenen Menülinks:** Das Textfeld bleibt, weil eine fremde Adresse getippt wird. Daneben stehen „Seite wählen“ und „PDF wählen“ (`link-field.js`, Markup `data-link-field`/`data-link-input`).
|
||||
- **Kategorien und Schlagwörter eines Beitrags:** Die vorhandenen Namen stehen als Knöpfe unter dem Feld. Ein Klick nimmt einen Namen auf oder heraus (`term-chips.js`), neue Namen tippt man weiter ein. So entstehen nicht „Projekt“ und „Projekte“ nebeneinander.
|
||||
|
||||
**Bekannt, nicht neu:** axe meldet im Menü-Editor `nested-interactive`, weil der Ziehknopf in der Zusammenfassung jedes Eintrags steckt. Die Editor-Prüfung prüft dort nur die neuen Felder.
|
||||
|
||||
**Geprüft:** neue Tests `EditorSecurityTest::testMenuAnchorsAndPostTermsAreOfferedForChoosing` und `…testPluginSettingsForLinksAndImagesAreChosenNotTyped`. Die Editor-Prüfung wählt im Menü eine Seite als eigenen Link, prüft die Sprungziel-Auswahl eines neuen Seitenlinks, legt einen Beitrag mit Kategorie an und nimmt die Kategorie per Klick auf und wieder heraus (jeweils mit axe).
|
||||
|
||||
## Fassung 1.22.2: Auswahl statt abgetippter Kennungen und Adressen (22.09.2026)
|
||||
|
||||
**Anlass:** Umut zu den Beitragseinstellungen: „nicht dein ernst? ID aus Mediathek? schöne auswahl rein! auch bei Beitragsbild“, dann: „Andere bereiche prüfen ob es sowas noch gibt!“
|
||||
|
||||
Die ganze Verwaltung und der Editor wurden nach Feldern durchsucht, in die man eine Kennung, eine Adresse oder einen technischen Namen tippen musste, der auf etwas anderes zeigt. Umgebaut:
|
||||
|
||||
- **Vorschaubild beim Teilen und Beitragsbild** (Seiten und Beiträge → Einstellungen): Bildfeld mit Vorschau, „Bild wählen oder hochladen“ im Mediathek-Dialog, „Entfernen“ (`admin/media-field.html.twig`, `media-field.js`, Twig-Funktion `kant_media`). Die Kennung steht nur noch im versteckten Feld.
|
||||
- **Plugin-Einstellungen der Art Bild:** dasselbe Bildfeld statt „Kennung aus der Mediathek“.
|
||||
- **Editor, Formular-Baustein, Sektions-Baustein, Beitragsliste (Kategorie, Schlagwort):** Auswahllisten statt Adressen. Die Listen kommen mit `/api/design` (`choices`). Eine gespeicherte Adresse, die es nicht mehr gibt, bleibt als „(nicht mehr vorhanden)“ sichtbar.
|
||||
- **Linkziele im Editor** (Button, Bild, Symbolkarte, CTA, Symbol, Link-Felder von Plugins) und **Links im Textbaustein:** „Seite wählen“ mit Seitensuche (`openPageDialog` in `page-picker.js`) neben „PDF aus Mediathek wählen“. Freie Adressen bleiben möglich. Der Schalter „In neuem Tab öffnen“ erscheint bei Plugin-Feldern nicht mehr, deren Schema kennt ihn nicht.
|
||||
- **Weiterleitungen, neue Adresse:** Seitensuche. Ein anderer Pfad (etwa `kategorie/projekte`) lässt sich weiter eintippen (`data-free` im Seitenwähler).
|
||||
- **Formular-Editor:** „Antwortadresse aus Feld“ wählt aus den E-Mail-Feldern, „Nur zeigen, wenn“ aus den Auswahlfeldern weiter oben, mit Kästchen für die Antworten. „Bild je Option“ öffnet den Mediathek-Dialog mit Vorschau statt einer Aufklappliste. Ohne Skript bleiben die Textfelder, die gespeichert werden.
|
||||
|
||||
**Bewusst getippt bleibt:** was an der Stelle erst entsteht (Adresse einer neuen Seite, eines Formulars, einer Sektion, Farb- und Schriftschlüssel, technischer Feldname, Anker), die alte Adresse einer Weiterleitung, Einbettungsadressen, Schlüssel für Turnstile. Noch offen: Link-Einstellungen von Plugins in der Plugin-Verwaltung (Textfeld), Sprungziel im Menü, Vorschläge vorhandener Kategorien und Schlagwörter in den Beitragseinstellungen.
|
||||
|
||||
**Geprüft:** neue Tests `EditorSecurityTest::testImageSettingsOfAPostArePickedFromTheLibraryNotTypedAsAnId` und `…testTheEditorPicksFormsSectionsAndTermsFromListsInsteadOfTypedAddresses`. Die Editor-Prüfung (`npm run test:editor`) wählt jetzt im Browser ein Vorschaubild und speichert es. Sie wählt eine Seite als Linkziel (mit axe) und prüft, dass Formular, Kategorie und Schlagwort Auswahlfelder sind. Im Formular-Editor wählt sie Bedingung, Antwortadresse und ein Optionsbild, legt das Formular an und liest es wieder (mit axe).
|
||||
|
||||
## Fassung 1.22.1: vergrößerbare Galerie mit einem Haken (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Bei Klick vergrößern bei Galerie muss man da alles einzeln machen? kann man bei Galerie das mit einem Klick für alle elemente machen?“
|
||||
|
||||
- Der Haken „Bilder vergrößerbar“ an der Galerie gilt jetzt für jedes Bild darin, das unter „Beim Klick“ nichts selbst gewählt hat. Das gilt auch für später hinzugefügte Bilder. Bisher schaltete er nur das Blättern im Popup ein, und jedes Bild musste einzeln auf „Bild vergrößern“ gestellt werden.
|
||||
- Eine eigene Wahl geht vor: „Nichts tun“ bleibt still, ein Link bleibt ein Link. „Nie eingestellt“ und „Nichts tun“ sind im Dokument unterscheidbar, weil der Editor `click` erst beim Ändern speichert. Bestehende Galerien ändern sich deshalb nur dort, wo der Haken gesetzt ist und ein Bild noch nie eingestellt wurde.
|
||||
- Der Editor zeigt bei einem solchen Bild „Bild vergrößern“ als aktuelle Wahl, nach derselben Regel wie die Vorlage (`galleryZoom` in `blocks/block.html.twig`).
|
||||
|
||||
**Geprüft:** neuer Test `CmsTest::testAZoomableGalleryMakesEveryImageWithoutItsOwnChoiceZoomable` (ohne Wahl, „Nichts tun“, Link, Galerie ohne Haken), volle Kette vor dem Release.
|
||||
|
||||
## PDFs in der Mediathek, als Linkziel und im Betrachter (22.09.2026)
|
||||
|
||||
**Anlass:** Umut: „kann man aktuell PDFs hochladen und als Button verlinken? … und ein Inline PDF Viewer wie Bild-Popup“, danach: „ja setz um, einfacher Browser-Betrachter“.
|
||||
|
||||
- **Hochladen:** Das Upload-Feld der Mediathek nimmt jetzt neben JPEG, PNG und WebP auch PDFs bis 20 MB an. Ob eine Datei ein PDF ist, entscheidet ihr Inhalt (`%PDF-` in den ersten 1024 Bytes), nicht die Endung. Die PDFs stehen in einer eigenen Tabelle `document` (Migration `20260917000100`) und nicht in `media`. Jede Bildauswahl erwartet dort Varianten mit Breite und Höhe. Die Datei liegt unverändert als `<id>.pdf` im Medienordner und geht deshalb mit jeder Sicherung mit. Die Mediathek zeigt die PDFs unter den Bildern, mit Titel und Archivieren.
|
||||
- **Ausliefern:** `/media/<id>.pdf` kommt mit `Content-Type: application/pdf`, `nosniff`, `inline` und dem Titel als Dateinamen, `X-Robots-Tag: noindex` und `frame-ancestors 'self'`: Einbetten darf es nur die eigene Website. Archivierte PDFs bleiben erreichbar, wie Bilder.
|
||||
- **Verlinken:** Jedes Linkziel (Button, Bild, Symbolkarte, CTA, Symbol) hat den Knopf „PDF aus Mediathek wählen“ mit einer Liste, Suche und Hochladen direkt im Dialog. Ins Feld kommt die Adresse `/media/<id>.pdf`, ein gewöhnlicher lokaler Pfad, also ohne neue Schemaversion.
|
||||
- **Betrachter:** `public/assets/lightbox.js` (dieselbe Datei wie der Bildbetrachter, damit eine Seite kein Skript mehr lädt) zeigt ein verlinktes PDF in einem `<dialog>` mit `<iframe>` und nutzt dafür den PDF-Betrachter des Browsers, dazu Herunterladen, „Im neuen Tab“ und Schließen. Wo der Browser keinen eingebauten Betrachter hat (`navigator.pdfViewerEnabled`) oder das Gerät ein Touchgerät ist, öffnet sich das PDF im neuen Tab: Android-Chrome hat keinen Betrachter, iOS-Safari zeigt im Rahmen nur die erste Seite. Ist „In neuem Tab öffnen“ gesetzt oder das Skript nicht geladen, bleibt es ein normaler Link. Skript und `frame-src 'self'` kommen nur auf Seiten, deren Dokumente (auch Kopf, Fuß, Sektionen und Fließtext) auf ein PDF der Mediathek zeigen.
|
||||
- **Grenzen:** Hochgeladene PDFs sind über ihre Adresse öffentlich. Kein PDF.js, also sieht der Betrachter je nach Browser anders aus. Chrome zeigt im Kopf des Betrachters die Kennung aus der Adresse, nicht den Titel. PDFs werden nicht gelöscht, nur archiviert.
|
||||
|
||||
**Geprüft:** zwei neue Tests (`CmsTest::testAPdfFromTheLibraryIsALinkTargetAndOpensInTheViewer`, `EditorSecurityTest::testPdfsGoThroughTheMediaUploadAndTheEditorPicker`: Ablehnung von Nicht-PDFs, Kopfzeilen, 404, Skript und Richtlinie nur mit PDF-Link, Formular-Upload, API mit Token, Archiv). Im Browser mit der Pilotkopie: Klick auf den Button öffnet das zweiseitige PDF im Popup, Schließen gibt den Fokus an den Button zurück, bei 375 px mit Touch öffnet es einen neuen Tab. Der Auswahldialog im Editor ist nur per Syntaxprüfung geprüft, nicht im Browser. Dafür wäre eine Anmeldung nötig.
|
||||
|
||||
## Siebte Installation: Landingpage „KANTARCI Immobilien – Mietanfrage“ (18.09.2026)
|
||||
|
||||
**Anlass:** Umut: „neues Projekt wir brauchen eine einfache landingpage nach gleichem prinzip. einfache landingpage impressum und datenschutz notwendig? baus ein“ – dazu das Paket `KANTARCI-Immobilien-Landingpage.zip` (Next.js/Vinext für Cloudflare mit D1-Datenbank: Kopf, dunkler Hero, Werteleiste, Mietanfrage, Fuß, ein Banner als einziges Bild). Später: „Formular gefällt mir noch nicht kannst du ein multipage formular machen ggfls mit icon checkbox“ und die Anbieterdaten (Geschäftsführer Ridvan Kantarci, Ooser Hauptstr. 51, 76532 Baden-Baden, info@kantarci-immo.de).
|
||||
|
||||
**Neu.** Datenbank `var/kantarci-immobilien.db`, Medien `var/kantarci-immobilien-media`, Bildquellen `var/kantarci-immobilien-assets` (das Banner, daraus zugeschnitten Logo, Hausmotiv und Seitensymbol, dazu `options/` mit 14 Bildkarten), Startkonfigurationen `kantarci-immobilien` (Port 8098, dev) und `kantarci-immobilien-prod` (Port 8099). `sh scripts/reset-kantarci-immobilien.sh` schneidet die Bilder zu (`scripts/build-kantarci-assets.php`, GD), zeichnet die Bildkarten (`node scripts/build-kantarci-options.mjs`, Chromium), migriert und baut mit `scripts/seed-kantarci-immobilien.php` Gestaltung, Kopf, Fuß, Startseite, Impressum, Datenschutz, 404, Einstellungen und das Formular `mietanfrage` auf. Schriften Arial und Georgia (System).
|
||||
|
||||
- **Impressum und Datenschutz sind Pflicht:** geschäftlicher Auftritt (§ 5 DDG) und ein Formular mit personenbezogenen Daten (Art. 13 DSGVO). Offen und im Text als „NOCH ZU PRÜFEN“ markiert: Handelsregister, Telefonnummer, USt-IdNr., Erlaubnis nach § 34c GewO (nur bei Vermittlung/Verwaltung für Dritte), Urheber des Hausfotos, Hosting- und Mail-Auftragsverarbeiter, Löschfrist.
|
||||
- **Mietanfrage in drei Schritten** mit Zusammenfassung: Haushalt (Bildkarten Allein, Zu zweit, Familie mit Kindern, Wohngemeinschaft; Personenzahl nur bei den beiden letzten), Wohnwünsche (Zimmer als Bildkarten, Ausstattung als Mehrfachauswahl mit sechs Bildkarten, Einzug, Warmmiete, Objekt), Kontaktdaten. Der Einzug ist eine Auswahl statt eines Datums – der Formularbaukasten kennt kein Datumsfeld.
|
||||
- **E-Mail:** Die Vorlage nennt `immo@kantarci-immobilien.de`, die Domain existiert aber nicht (kein DNS, 18.09.2026). Überall steht jetzt `info@kantarci-immo.de` (MX vorhanden), auch als Empfänger der Anfrage.
|
||||
- **CMS-Lücken beim Aufbau:** eigenes CSS kennt kein `@media` – Raster, die am Telefon einspaltig werden sollen, laufen über `repeat(auto-fill,minmax(…))`. Verschachtelte Selektoren erlauben keine Umlaute, also auch kein `[data-label="Gewünschter Einzug"]` (Feld heißt deshalb „Einzugstermin“). Die Zusammenfassung ist selbst ein `.form-step`; wer die Schritte zweispaltig setzt, muss `.form-summary-body` über beide Spalten legen. `<small>` in einem Textbaustein wird entfernt. Die Fehlermeldung des Formulars duzt („Bitte prüfe …“), die Seite siezt.
|
||||
|
||||
**Geprüft:** Aufbau per Reset-Skript fehlerfrei; Startseite, Impressum, Datenschutz 200, unbekannte Adresse 404; im Browser (1280 px und 375 px, kein seitliches Scrollen) alle drei Schritte, Bedingung Personenzahl, Zusammenfassung und Absenden – die Anfrage liegt vollständig in `form_submission`. Mailversand ist lokal nicht eingerichtet. Lighthouse und axe noch nicht gelaufen.
|
||||
|
||||
## Adresse der Website in der Verwaltung (21.09.2026)
|
||||
|
||||
**Anlass:** Umut: „ja bau die Einstellung in der Verwaltung ein sodass es in die env eingetragen wird und übernommen wird“ – mit oder ohne www wie die „WordPress-Adresse“, statt nur über `cms:site:origin` oder die `.env` von Hand.
|
||||
|
||||
- **System → Diese Installation → Adresse** hat ein Feld „Adresse der Website“ und einen Link, der direkt auf die andere Schreibweise (mit/ohne www) umstellt. Gespeichert wird `SITE_ORIGIN` in `.env` über `Environment::write`. Das gilt ab der nächsten Anfrage, weil `%env()%` zur Laufzeit gelesen wird. Kommt `SITE_ORIGIN` aus der Server-Umgebung, gibt es kein Feld, und ein POST wird abgewiesen.
|
||||
- **Schutz vor dem Aussperren:** Vor dem Speichern fragt die Installation sich selbst unter der neuen Adresse an (`OriginCheck`, `GET /.well-known/kantsites-origin`). Zurückkommen muss ein HMAC aus `APP_SECRET`, den nur diese Installation kennt. Ohne DNS-Eintrag oder Zertifikat für www wird nichts geändert – sonst leitete die andere Schreibweise ins Leere, die Verwaltung eingeschlossen. Erst nach einer Ablehnung erscheint „Trotzdem übernehmen“ (etwa wenn der Server sich selbst nicht erreicht, oder bei http im lokalen Betrieb). Die Prüfadresse ist auch im Wartungszustand erreichbar.
|
||||
- Wechselt die Verwaltung dadurch den Host, ist dort eine neue Anmeldung nötig. Das Sitzungscookie gilt nur für die Schreibweise, unter der es entstand. Die Meldung sagt das.
|
||||
|
||||
**Geprüft:** zwei neue Tests in `SystemScreenTest` (Server-Umgebung ohne Feld, Ablehnung einer nicht erreichbaren Adresse ohne Schreiben in `.env`, falsche Eingaben, Prüfadresse; `OriginCheck` mit fremder und eigener Kennung). Im Browser mit der Pilotkopie bei 1280 und 375 px (kein seitliches Scrollen) Feld, Ablehnung und „Trotzdem übernehmen“ angesehen.
|
||||
|
||||
## Favicon auch für die Google-Suche (21.09.2026)
|
||||
|
||||
**Anlass:** Umut: Google zeigt bei Elektro Bernhard weiter das KantSites-K, obwohl ein eigenes Symbol gesetzt ist – „mach das im Kern sobald man Favicon setzt alles ersetzen“.
|
||||
|
||||
- **Ursache:** Das eigene Symbol gab es nur in 32 und 180 px, Google nimmt nur Vielfache von 48 px. Und `/favicon.svg` lag als Datei in `public/`, der Webserver lieferte also immer das K aus – dort hatte Google es beim ersten Besuch gefunden.
|
||||
- **Neu:** `FAVICON_SIZES` sind 32, 48, 180 und 192. Der Kopf verweist zusätzlich auf `/favicon-192.png` (`sizes="192x192"`). `/favicon.ico` liefert das 48er-PNG. `/favicon.svg` beantwortet jetzt die Anwendung: ohne eigenes Symbol das mitgelieferte Zeichen (liegt jetzt unter `public/assets/favicon.svg`), mit eigenem ein 302 auf `/favicon-192.png`. Fehlen bei älteren Installationen die neuen Größen, entstehen sie beim ersten Abruf aus der gespeicherten Einstellung. Wird das Symbol entfernt, gelten die liegengebliebenen PNGs nicht mehr.
|
||||
- **Update:** Das Archiv-Update kopiert `public/` nur hinzu; `ENTFALLEN_IN_PUBLIC` löscht deshalb die alte `public/favicon.svg`, sonst bliebe sie auf dem Server und überdeckte die Route.
|
||||
- **Danach bei Google:** In der Search Console die Startseite unter „URL prüfen“ neu zur Indexierung einreichen. Google holt Favicons selten neu ab, das kann trotzdem Tage bis Wochen dauern.
|
||||
|
||||
**Geprüft:** neuer Test `testGoogleGetsTheOwnSiteIconUnderEveryAddress` und der bisherige Favicon-Test grün, Update-Tests unverändert (eine vorbestehende Warnung), php-cs-fixer sauber; lokal (ohne eigenes Symbol) `/favicon.svg` 200, `/favicon.ico` → `/favicon.svg`.
|
||||
|
||||
## Favicon mit Zuschnitt, Pflichtseiten per Suche, neueste zuerst (18.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Was ein Dropdown? nicht dein ernst? Sofort fixen über Mediathek auswählbar machen zurecht schneidbar machen und auch uploadbar dort machen!“ – dazu „Genauso Adresse Datenschutz und Impressum über search felder auswählbar machen“ und „Beiträge und Seiten sortieren absteigend also neueste zuerst“.
|
||||
|
||||
- **Symbol der Website** hat einen eigenen Abschnitt in den Einstellungen statt einer Auswahlliste am Ende der Unternehmensdaten. Der Knopf öffnet die Mediathek (dort auch Upload), danach folgt ein Zuschnitt: Quadrat ziehen, Größe an der Ecke, per Regler oder mit +/−, Pfeiltasten zum Verschieben, Vorschau in 180 und 32 px. Gespeichert wird `faviconCrop` als `links,oben,kante` in Anteilen (links/oben von Breite/Höhe, Kante von der kürzeren Seite), `renderFavicon()` schneidet daraus die PNGs. `faviconVersion` hängt am Verweis; ohne Zuschnitt bleibt es bei den ersten acht Zeichen der Bildkennung, bestehende Verweise ändern sich also nicht.
|
||||
- `openMedia()` kennt `needsAlt: false`: Für das Symbol wird kein Alternativtext verlangt. Die Stile des Mediathek-Dialogs stehen jetzt in `media-dialog.css` statt in `editor.css`, damit der Dialog auch außerhalb des Editors funktioniert.
|
||||
- **Datenschutz und Impressum** werden über die Inhaltssuche gewählt wie die 404-Seite. Die Suche kann jetzt statt der Kennung den Pfad speichern (`valueKey: 'path'` bzw. `data-value="path"`), gespeichert wird also weiter die Adresse.
|
||||
- **Seiten- und Beitragsliste** der Verwaltung sortieren nach Anlagedatum absteigend. Die Einrückung der Unterseiten ist dort entfallen – ohne Baumreihenfolge hätte sie eine falsche Elternseite angedeutet.
|
||||
|
||||
## Sechste Installation: der Redesign-Entwurf „EB Solar“ (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „bitte redesign aufsetzen ebsolar.de achte auf die richtlinien wie immer machs wie die anderen tagespflege, elektro-baernhard co“ – dazu das Paket `ebsolar-redesign.zip` (statische Website: Startseite, Projektübersicht, acht Projektseiten, eigene SVG-Symbole, Kontaktformular per `mailto`).
|
||||
|
||||
**Neu.** Neben Kant-IT, Elektro Bernhard, Tagespflege Moldovan, Maler Drews und DEEG steht jetzt **EB Solar** (Elektro Bernhard Regenerative Energie GmbH, Baden-Baden): Datenbank `var/ebsolar.db`, Medien `var/ebsolar-media`, Bildquellen `var/ebsolar-assets` (Logo, Favicon, Startbild, sechs Herstellerlogos, 31 Projektfotos, `projects.json`), Startkonfigurationen `ebsolar` (Port 8096, dev) und `ebsolar-prod` (Port 8097, `APP_ENV=prod` zum Messen). `scripts/seed-ebsolar.php` baut Gestaltung, Kopf- und Fußbereich, den Kontaktbereich als **wiederverwendbare Sektion**, Startseite, Projektübersicht, Solarcheck, Impressum, Datenschutz, 404, acht Projektbeiträge, Menü und zwei Formulare; `sh scripts/reset-ebsolar.sh` wirft weg, migriert und baut neu. Schrift ist Arial (System) wie in der Vorlage, es gibt nichts zu holen.
|
||||
|
||||
**Aus der Vorlage übernommen:** Farben (`#b52c2c` Rot, `#252b30` Tinte, `#f5f6f6` helle Fläche, `#e6e8e8` Linie), Schriftgrößen, Abstände, Wortlaut und die Reihenfolge der Abschnitte – Hero mit Bildunterschrift über dem Foto, die vier Zusicherungen zwischen zwei Linien, drei Schritte, „Gutes Handwerk. Neue Energie.“ auf heller Fläche, Projektraster, Herstellerleiste, „Was kann Ihr Dach?“.
|
||||
|
||||
**Bewusste Abweichungen.**
|
||||
- Das Kontaktformular der Vorlage öffnet nur das E-Mail-Programm des Besuchers. Hier ist es das serverseitige Formular `kontakt`; die Anfrage liegt in der Verwaltung und geht an pv@elektro-bernhard.de.
|
||||
- Der **Solarcheck** ist im Paket nur ein Verweis auf ebsolar.de. Hier ist er eine eigene Seite mit dem Formular `solarcheck`: dieselben acht Schritte und dieselben Fragen wie im Original (abgerufen 16.09.2026), nur der Schieberegler für den Jahresverbrauch ist ein Zahlenfeld – ein Schieberegler ist im Formularbaustein nicht vorgesehen.
|
||||
- Die acht Projektseiten sind **Beiträge** der Kategorie „Projekte“ mit Galerie und Bildbetrachter; `/projekte` ist eine Beitragsliste. Die Projektart („Photovoltaik“, „Terrassenmontage · SolarEdge“) steht als **Schlagwort** am Beitrag und nicht als Kategorie: „vorheriges/nächstes Projekt“ folgt der ersten Kategorie, und die muss bei allen acht dieselbe sein, sonst führt die Navigation an Rheinstetten vorbei. Die Beitragsliste zeigt nur Kategorien, deshalb tragen die Karten anders als in der Vorlage keine Artzeile.
|
||||
- Impressum und Datenschutz sind eigene Seiten (Angaben vom Impressum der laufenden Website); was nur Elektro Bernhard beantworten kann, steht als „NOCH ZU PRÜFEN“ im Text.
|
||||
- Kein Cookie-Banner: es wird nichts von Dritten geladen.
|
||||
|
||||
**Bildkarten im Solarcheck (17.09.2026).** Umut: „du kannst so schöne svg's erstellen kannst du bitte noch schöne icons für den solarcheck machen statt checkboxe schöne bilder?“ Die fünf Auswahlfragen (Personen, Dachform, Dachfläche, Eindeckung, Speicher) zeigen jetzt Bildkarten statt Optionsfelder – 20 Symbole in Tinte und Rot auf heller Fläche, im Stil der übrigen Symbole der Website. `scripts/build-ebsolar-options.mjs` zeichnet sie als SVG und rastert sie mit Chromium auf 800×600 (4:3 wie die Karte), weil die Mediathek kein SVG nimmt; SVG und PNG liegen in `var/ebsolar-assets/options/`, das Aufbauskript hängt die PNGs per `images` an die Felder. Lighthouse `/solarcheck` danach: Leistung 98/98, Barrierefreiheit, Best Practices, SEO 100.
|
||||
|
||||
**Im Kern behoben: Pfeile waren Schriftzeichen.** Der Button haengte ein `↗` an, die Beitragsnavigation ein `←`/`→` – Buchstaben also, die je nach Schrift anders aussehen und mit der Schriftstaerke nicht mitwachsen. Der Symbolsatz (`templates/blocks/icon.html.twig`, `DocumentValidator::ICONS`) kennt jetzt drei Pfeile: `arrow-right`, `arrow-left`, `arrow-up-right`, gezeichnet wie die uebrigen 17 Symbole. Button und Beitragsnavigation setzen sie als eingebettetes SVG in denselben `<span aria-hidden>` wie bisher – `& span{display:none}` nimmt den Pfeil also weiterhin weg, wo eine Vorlage keinen will (DEEG). Die Groesse haengt an der Schrift des Knopfs (`.button-arrow>svg{width:1.15em}`), nicht an einer festen Zahl. Auf EB Solar tragen auch die Textlinks ihren Pfeil als Symbolbaustein neben dem Text; er zeigt auf dieselbe Adresse und ist als Schmuck von Vorlesesoftware und Tastatur ausgenommen.
|
||||
|
||||
**Im Kern behoben: zweimal derselbe Selektor in eigenem CSS loeschte die erste Regel.** `CustomCode::parse()` legte die Deklarationen je Selektor in einem Array ab – `$rules[$suffix] = …`. Standen `& .post-card{border:0}` und `& .post-card{transform:none}` untereinander, gewann die zweite, und `border:0` verschwand ohne Meldung; im Projektraster von EB Solar kam dadurch der 1-px-Rahmen des Kerns zurueck. In CSS ist zweimal derselbe Selektor eine Kaskade, kein Widerruf: die Deklarationen werden jetzt in der Reihenfolge zusammengefuegt, in der sie standen. Dasselbe galt fuer `&{…}` neben den Eigenschaften des Elements selbst – auch das ging vorher verloren.
|
||||
|
||||
**Im Kern behoben: das vorgezogene Bild wurde nicht vorab geholt.** Für ein Hintergrundbild des ersten Abschnitts schreibt `page.html.twig` längst ein `<link rel="preload" as="image">` (`heroBackground`). Für ein gewöhnliches `<img>` über der Falz fehlte das Gegenstück – der Browser fand es erst, wenn das Stylesheet geparst war, und diese Rundreise ging auf die Startseite von EB Solar (deren Startbild ein Bild und kein Hintergrund ist) direkt in die LCP-Zeit. Neu liefert `PageController::heroImage()` Quelle, Variantensatz und Größenangabe genau so, wie `blocks/block.html.twig` sie an das `img` schreibt (sonst lädt der Browser zweimal), und die Vorlage schreibt daraus die Vorabladung – aber nur, wenn nicht ohnehin schon ein Hintergrundbild vorgeholt wird.
|
||||
|
||||
**Und eine Regel fürs Aufbauen:** eine Einblendung auf dem größten Element über der Falz kostet fast eine Sekunde LCP (gemessen: 916 ms „element render delay“, mobil 92 statt 99). Das Startbild blendet deshalb nicht ein; Kleinzeile, Überschrift, Text und Knöpfe daneben schon.
|
||||
|
||||
**Geprüft:** `npm run test:links` gegen Port 8096: 17 Adressen in der Sitemap, 48 Seiten, 127 Adressen, kein toter Verweis. `npm run test:browser` (`PHP_BINARY=C:/php84/php.exe`): sechs Seiten-/Viewport-Prüfungen mit axe, Tastatur und ohne JavaScript bestanden, **keine redaktionellen Befunde**; Ladebudget der Startseite 14 Anfragen, 17 Abfragen. Lighthouse 13.4.1 (`scripts/audit.mjs`, je drei Läufe mobil und Desktop, Mediane) gegen den Produktionsmodus auf Port 8097 für `/`, `/projekte`, `/solarcheck`, `/michelbach`, `/rheinstetten`, `/impressum`, `/datenschutz`: **Barrierefreiheit, Best Practices und SEO überall 100**, Leistung 99–100, agentisches Browsing überall 100 außer `/solarcheck` mobil (98). Startseite LCP 2,1 s mobil / 0,8 s Desktop, CLS 0, TBT 0 ms; Berichte in `reports/lighthouse-ebsolar/`. Bildbetrachter der Galerie geöffnet und geblättert („1 von 4“), Beitragsnavigation stimmt mit der Reihenfolge der Vorlage überein (Michelbach ← Rheinstetten, → Au am Rhein), 404 und Solarcheck im Browser angesehen, Desktop und 375 px. PHP-Tests **581 grün** (PHP 8.4), php-cs-fixer und Twig-Lint ohne Befund.
|
||||
|
||||
**Stolperstein beim Messen:** Im Produktionsmodus liegen die uebersetzten Twig-Vorlagen in `var/cache/prod`. Nach einer Aenderung an `blocks/icon.html.twig` warf jede Seite mit Pfeil dort **500** („Key ‚arrow-right‘ … does not exist“), waehrend der Entwicklungsserver auf 8096 laengst richtig lieferte. `APP_ENV=prod … bin/console cache:clear` raeumt das auf; wer im Produktionsmodus misst, muss das nach jeder Vorlagenaenderung tun.
|
||||
|
||||
**Bekannt:** `/solarcheck` mobil hat CLS 0,08 (agentisches Browsing 98). Das Formularskript blendet nach dem Laden alle Schritte bis auf den ersten aus – ohne Skript stehen sie untereinander, mit Skript springt die Seite einmal. Der Wert liegt unter dem Ziel von 0,1, aber die Bewegung ist sichtbar; sie gehört in den Kern (Schritte serverseitig zusammenfalten), nicht ins Aufbauskript.
|
||||
|
||||
|
||||
## Startseite nach der Installation: eine Baustellenseite statt einer Anleitung (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Kannst du bitte für die Erstseite bzw. nach der Installation eine schöne Erstseite erstellen – Under Construction oder so, ohne Details mit ‚melde dich an‘ usw. Am besten auch noch Logo von KantSites: ‚Hier entsteht eine neue Webpräsenz mit KantSites‘.“
|
||||
|
||||
**Was los war.** `DemoContent::starterPage()` lieferte zwei Abschnitte mit Arbeitsanweisungen an die Betreiberin oder den Betreiber: „Melde dich an, öffne sie im Editor …“ und „Die nächsten Schritte“ mit Design, Menüs und Einstellungen. Das stand auf der einzigen Seite, die bis zum ersten Bearbeiten **öffentlich** erreichbar ist – gelesen hat es also der Besucher, nicht der Betreiber. Für den stehen dieselben Schritte längst auf dem letzten Installerbildschirm (`templates/install/done.html.twig`) und in der Verwaltung.
|
||||
|
||||
**Neu.** Eine Baustellenseite, die genau das sagt und sonst nichts: das KantSites-Zeichen (`public/assets/kantsites-logo.svg`, dieselbe Form wie das Favicon), die Kleinzeile „In Arbeit“ in der Akzentfarbe, als einzige H1 der Name der Website, darunter „Hier entsteht eine neue Webpräsenz.“, ein kurzer Trennstrich und die Zeile „Erstellt mit KantSites“. Der Abschnitt ist ein Flex-Container mit `min-height: 70vh` und einem Verlauf von `@paper` nach `@mint`, stellt den Inhalt also mittig auf den Bildschirm; die Farben sind Palettenverweise und folgen jedem Designwechsel. Alles sind gewöhnliche Bausteine mit gewöhnlichen Stilwerten – im Editor also vollständig änder- und löschbar. Die Seitenbeschreibung ist jetzt ein echter Satz statt „NOCH AUSZUFÜLLEN“, damit eine frisch installierte Website in Suchergebnissen nicht mit einem Platzhalter steht.
|
||||
|
||||
**Unverändert:** Kopf- und Fußbereich, die beiden Rechtsseiten als Gerüst mit markierten Pflichtangaben, Menüs.
|
||||
|
||||
**Geprüft:** `tests/InstallFlowTest.php` (Installation über die sechs Bildschirme, danach liefert `/` die neue Seite), `php vendor/bin/phpunit` 579 von 579. Im Browser angesehen: eine frisch migrierte Datenbank mit dem Inhalt einer Neuinstallation, Desktop und 375 px.
|
||||
|
||||
## Fassung 1.21.0: Bildecken gehören dem Designbereich (16.09.2026)
|
||||
|
||||
**Anlass:** Umut zum Hero-Bild von DEEG: „Das sollte abgerundet sein, aber wurde von irgendwo gemacht, und die Ecken sehen komisch aus. Es gibt auch im Editor gar nicht Bildecken abrunden, oder?“
|
||||
|
||||
**Was los war.** `.block-image{border-radius:12px}` stand fest in `enhancements.css` – jedes Bild jeder Installation war gerundet, ohne dass ein Feld das gesagt hätte. Auf DEEG (eine durchgehend eckige Vorlage) fiel es auf, weil über dem gerundeten Bild eckige Elemente liegen: der dunkle Verlauf, das Schild, die blaue Aufruf-Fläche. An den Ecken schaute deshalb die eckige Fläche aus der Rundung heraus.
|
||||
|
||||
**Behoben.** Die Ecken sind jetzt eine Einstellung unter **Design → Bildecken** (`theme.base.imageRadius`, geprüft wie die Inhaltsbreite, ausgegeben als `--k-image-radius`). Vorgabe bleibt `12px`, damit bestehende Websites gleich aussehen; `0px` macht alle Bilder eckig. Für ein einzelnes Bild gab es die Einstellung schon immer – im Editor unter **Design → Bild → Bildecken** (`imageRadius` im Stilschema, für Bild, Galerie, Logoleiste und Beitragsliste); sie überschreibt den Wert der Website.
|
||||
|
||||
**DEEG:** `imageRadius` auf `0px`, im Aufbauskript und in der Installation. Das Hero-Bild ist damit eckig wie die Vorlage, und die Ecken stimmen wieder.
|
||||
|
||||
**Geprüft:** neuer Test in `tests/CmsTest.php`: Vorgabe ist `12px`, eine Seite mit Bild trägt `--k-image-radius:12px`, nach dem Speichern von `0px` trägt sie `--k-image-radius:0px`, und „rund“ wird abgewiesen wie jede andere Nicht-Größenangabe.
|
||||
|
||||
## Fassung 1.20.1: das kaufmännische Und, das bei jedem Speichern wuchs (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Beim Startseitenbild wird der Text & umgewandelt, aber in der Ansicht nach dem Speichern wird & daraus.“
|
||||
|
||||
**Ursache.** `RichText::sanitise()` hat Text ohne ein einziges Tag als reinen Text behandelt und nur escaped. Was der Editor schickt, ist aber HTML – auch ohne Tag. Aus `Freudenstadt & Umgebung` wurde so bei **jedem** Speichern eine Stufe mehr: `&amp;`, `&amp;amp;` … bis die Entity sichtbar auf der Seite stand. Auf kant-it.de war der Entwurf der Startseite schon bei drei Stufen. Derselbe Zweig hatte einen zweiten Fehler gleicher Art: `nl2br()` ließ den Zeilenumbruch hinter dem `<br>` stehen, und der nächste Durchlauf machte über `breakLines()` ein zweites `<br>` daraus – aus einem Absatz wurden zwei.
|
||||
|
||||
**Behoben.** Der Zweig dekodiert jetzt zuerst (`html_entity_decode`) und escaped danach, und er ersetzt den Umbruch, statt ihn zu ergänzen. Damit endet er bei derselben Darstellung wie der DOM-Zweig, und beide sind unveränderlich gegenüber wiederholtem Speichern.
|
||||
|
||||
**Bestand.** Der Konsolenbefehl `bin/console cms:repair:entities` räumt auf, was schon gespeichert ist: veröffentlichte Fassung und Entwurf jeder Seite sowie die Bereiche, mit Trockenlauf und `--schreiben`, jede Änderung gegen den Validator geprüft (Seiten wie beim Speichern, Bereiche ohne Überschriftspflicht). Als Befehl und nicht als Skript unter `scripts/`, weil dieser Ordner nicht ins Releasearchiv geht – eine Installation ohne Git hätte das Skript sonst nie gesehen. Ältere Fassungen bleiben Historie. Auf DEEG angewandt (eine Stelle im Entwurf der Startseite). **Offen: kant-it.de** – dort stehen zwei dreifach kodierte Stellen im Entwurf der Startseite; das Skript gehört nach dem Update dort einmal ausgeführt, sonst steht der Murks beim nächsten Veröffentlichen live.
|
||||
|
||||
**Geprüft:** `tests/RepairEntitiesCommandTest.php` (Trockenlauf ändert nichts; Schreiben entschachtelt drei Stufen und lässt ein richtiges `&` stehen; ein zweiter Lauf findet nichts mehr) und ein neuer Test in `tests/CmsTest.php`: sechs Eingaben (Entity, nacktes Und, `<`, Zeilenumbruch, Markup, wörtliche Tags) dreimal hintereinander durch `sanitise()` – jedes Mal dasselbe Ergebnis; dazu eine Seite, die dreimal gespeichert und veröffentlicht wird und danach immer noch ein einziges `&` trägt.
|
||||
|
||||
**Nebenbei (DEEG):** Das Favicon zeigte den mittigen Ausschnitt des Logos, also „EE“ aus dem Schriftzug. Neu ist ein eigenes quadratisches Zeichen: der Dachgiebel aus dem Logo, weiß auf Logo-Blau, gerendert aus `logo-deeg.png` von `scripts/set-immobilien-deeg-favicon.php`. Vier Entwürfe standen zur Wahl (Doppeldach, Giebel, Dach blau auf weiß, Buchstabe D), Umut hat den Giebel gewählt.
|
||||
|
||||
## Baustein „Eigenes HTML“, DEEG-Angebotsseite (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Als Baustein im Editor, dass man individuelles HTML einfügen kann … z. B. eine neue Angebotsseite bei DEEG, da soll ein iframe rein, im gleichen Stil wie das Impressum.“
|
||||
|
||||
**Gebaut.** Neuer Kernbaustein `html` („Eigenes HTML“, Dokumentschema **v24**): eine Eigenschaft, das Markup bis 20.000 Zeichen, unverändert ausgegeben. `CustomCode::checkHtml()` weist `<script>` und `<iframe>` mit dem Weg zum richtigen Ort ab; `CustomCode::assertHtmlUnchanged()` hält Änderungen auf die Administratorrolle fest – in `EditorController` (Speichern und Zurückholen einer Fassung) und in `PartialController`, wie beim eigenen Skript. Im Editor: Eintrag in der Gruppe „Seite und Daten“, mehrzeiliges Feld, schreibgeschützt ohne Administratorrolle. In `site.css` nur so viel Regel, dass fremdes Markup die Spalte nicht sprengt.
|
||||
|
||||
**Nicht gebaut, weil vorhanden:** der iframe. Der Baustein „Externer Inhalt“ (v14) macht das seit Langem – mit Einwilligung davor und dem Host in `frame-src`. Begründung in ADR 106.
|
||||
|
||||
**DEEG.** Die Seite `/angebote` gab es schon (im Editor angelegt, Starterabschnitt plus Rahmen mit Platzhaltertexten „test“). Sie ist jetzt im Stil der Rechtsseiten aufgebaut: Kleinzeile, große leichte Überschrift „Unsere Angebote“, Vorspann auf heller Fläche, darunter die IS24-Ergebnisliste (Portal 16200281, Höhe 1000) und eine Hinweiszeile zum Portal und zum Kontakt. „Immobilien“ in der Navigation und der Knopf im Angebote-Band der Startseite zeigen auf `/angebote` statt auf die alte Website. Aufbau per Skript: `scripts/add-immobilien-deeg-angebote.php`.
|
||||
|
||||
**Einwilligung und Gestaltung (DEEG).** Die Gruppe „Externe Inhalte“ ist eingeschaltet (`consentBanner`, `consentGroups`), der Balken trägt die Farben der Website (eckig, Logo-Blau als Akzent, Papier als Fläche). Vor der Zustimmung steht der Platzhalter, und der trägt über eigenes CSS am Block denselben Zuschnitt wie die Vorlage: eckiger Rahmen auf heller Fläche, linksbündig, blauer Knopf. **Gestaltbar ist der Rahmen, nicht sein Inhalt** – Abstände, Breite, Rand, Ecken und Höhe gehören dieser Website, die Darstellung der Liste gehört ImmobilienScout24; an fremdes Dokument im iframe kommt kein CSS heran, das trennt der Browser. Im Browser geprüft: Platzhalter, Zustimmung, geladene Liste.
|
||||
|
||||
**Nicht mehr offen:** Die Einwilligungsfrage war auf dieser Installation aus (`consentBanner` false, keine Gruppen). Das ist erledigt: Umut hat die Einwilligung eingeschaltet, ein fremder Rahmen lädt jetzt erst nach der Zustimmung.
|
||||
|
||||
**Geprüft:** `phpunit` 574 Tests grün, `npm test` 35 (neu: der Baustein startet leer, steht im Werkzeugkasten, hebt das Dokument auf v24). `npm run test:editor` um den ganzen Weg erweitert und bestanden: Baustein aus dem Werkzeugkasten einfügen, Markup eintippen, in der Vorschau sehen, veröffentlichen, auf der Website Zeichen für Zeichen wiederfinden – und `<script>` wie `<iframe>` werden mit ihrer Meldung abgewiesen (422, Meldung in der Leiste über dem Editor), ohne die veröffentlichte Seite anzufassen. Zwei neue PHP-Tests: Markup erscheint unverändert auf der veröffentlichten Seite und `<script>`, `<iframe>` sowie Schema v23 werden abgewiesen; HTML-Änderungen werden gegen den gespeicherten Stand geprüft.
|
||||
|
||||
## Fassung 1.19.0 veröffentlicht (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Ja committe das bitte und push mit neuem Release.“ Die Rücknahme der Designpakete geht als 1.19.0 heraus.
|
||||
|
||||
**Dabei aufgefallen:** Die Commits `c8a8fa7` und `53fb713` (v1.18.0) hatten den Arbeitsstand dieser Sitzung schon mitgenommen – in v1.18.0 fehlen `applyTheme()` und `plugins/nachtblau` bereits, während die mitgelieferte Dokumentation Designpakete noch beschreibt. 1.19.0 bringt beides wieder zur Deckung. Der Code ändert sich zwischen 1.18.0 und 1.19.0 nur um die Rollenmatrix des Tests.
|
||||
|
||||
**Geprüft:** `phpunit` 572 Tests in einem Prozess grün (PHP 8.4), `npm test` 34, Twig-Lint 53 Dateien, php-cs-fixer ohne Befund, `npm run test:editor` bestanden (Bildschirm „Plugins“ ohne Designpaket-Knopf, Barrierefreiheitsprüfungen durch). DEEG-Startseite im Browser gesichtet: eigene Palette wieder da. Danach Commit, Tag `v1.19.0`, Push nach `origin` und Releasearchiv.
|
||||
|
||||
## Designpakete in Plugins entfernt (16.09.2026)
|
||||
|
||||
**Anlass:** Umut auf der DEEG-Installation: „Ich habe das Designplugin aktiviert und nach dem Deaktivieren blieben die Farben.“ Nicht der Schalter war schuld, sondern „Designpaket übernehmen“ – der Knopf schreibt die Palette des Plugins endgültig in die Website. Entscheidung: die Funktion fällt weg, statt sie abzusichern (ADR 105).
|
||||
|
||||
**Gemacht.** DEEG-Design aus `scripts/seed-immobilien-deeg.php` wieder eingespielt (Logo-Blau, Anthrazit, Arial, eckige Buttons; Inhalte und Stilvorlagen waren nie betroffen). Aus dem Kern entfernt: `PluginRegistry::themePackage()` und `applyTheme()`, `theme` im Manifest und in der Pluginstruktur, Route `admin_plugin_theme` samt `Theme`-Abhängigkeit von `PluginAdminController`, der Knopf und die Designpaket-Zeile auf dem Bildschirm „Plugins“, der Ordner `plugins/nachtblau`. Ein Manifest braucht jetzt mindestens einen Baustein, eine Adresse oder eine Vorlage.
|
||||
|
||||
**Geprüft:** `tests/PluginTest.php` hat statt des Designpaket-Tests jetzt „Ein- und Ausschalten lässt das Design in Ruhe“ (Palette vorher und nachher gleich, Dokument unverändert); 8 Tests grün.
|
||||
|
||||
## Fassung 1.18.0 veröffentlicht (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Bau eine neue Version bitte, Commit, Push und Release.“ Der Stand aus DEEG-Nachbau, Formularschritten, Pluginsystem und den Rückständen der Gesamtpruefung geht als 1.18.0 heraus.
|
||||
|
||||
**Vorher gefunden und behoben:** Der komplette `phpunit`-Lauf in einem Prozess starb reproduzierbar nach 80 Sekunden mit „Maximum execution time exceeded“, an wechselnden Stellen. Ursache war nicht ein Test, sondern `TaskWorkerSubscriber`: `set_time_limit()` **setzt** die Grenze, es verlängert sie nicht. Auf einem Prozess ohne Grenze – Konsole, Testlauf, Hoster mit `max_execution_time = 0` – machte `set_time_limit($budget + 60)` aus „unbegrenzt“ achtzig Sekunden, und der Prozess starb später irgendwo weit weg vom Aufgabenplan. Derselbe Fall stand im Takt der Verwaltung (`TaskController::tick()`, 120 Sekunden). Beide Stellen heben die Grenze jetzt nur an und senken sie nie. Einzeln liefen alle Testdateien immer durch, deshalb war das bisher nie aufgefallen.
|
||||
|
||||
**Geprüft:** neuer Test in `tests/SchedulerTest.php` („der Worker kürzt einen Prozess ohne Zeitgrenze nicht“): faellige Aufgabe läuft, `max_execution_time` bleibt 0. `phpunit` 572 Tests, 4768 Zusicherungen in **einem** Prozess grün (vorher Abbruch), `npm test` 34, Twig-Lint mit `plugins/`, php-cs-fixer. Danach Commit, Tag `v1.18.0`, Push nach `origin` und Releasearchiv.
|
||||
|
||||
## Rückstände der Gesamtprüfung abgearbeitet (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Bitte umsetzen“ zur Liste aus ADR 100. ADR 104 beschreibt jeden Punkt; hier die Kurzfassung.
|
||||
|
||||
- **Archivseiten** laufen durch `renderPage()` (eigenes Dokument aus Kopfabschnitt und blätternder Beitragsliste): Kopf/Fuß mit Stilen, Sektionen, eigenes CSS, Bündel, Nach-oben, Einwilligung, Skripte. `templates/archive.html.twig` gelöscht.
|
||||
- **Formulare, Beitragslisten, Brotkrumen in Bereichen und Sektionen** werden aufgelöst; Meldungen eines abgewiesenen Formulars finden den Block über die Formularadresse. Skripte in Bereichen laufen als `/assets/bereich/<id>.js`.
|
||||
- **Doppelte Block-IDs** bei mehrfach verknüpfter Sektion: `PartialRepository::expandRepeatedLinks()` erzeugt Kopien mit eigenen Kennungen (Website und Editorvorschau).
|
||||
- **Breakpoints** überall 1024/767 (design.css neu gebaut, site.css, enhancements.css, preview.css, editor.css, Editortext).
|
||||
- **Farben**: `color-scheme`, Skip-Link, Aufruf-Button, Fehlerfarben, `.tone-ink`-Text aus der Palette; `Theme::css()` setzt `--k-scheme` und die Fehlerfarben nach Leuchtdichte des Papiers; Formularbutton dunkelt in die Tinte. Druckstile neu.
|
||||
- **Betrieb**: Sicherung/Wiederherstellung auf MySQL (mysqldump/mysql), Git-Updater sichert und rollt über `Backup`, Sperre `var/update.lock` für beide Wege, Archiv als Datei geladen, `.htaccess` zusammengeführt (HTTPS/HSTS bleiben an), `var/update/` und alte Caches nach dem Update aufgeräumt, CI-MySQL-Job mit `cms:health`, `cms:backup` und `phpunit`.
|
||||
- **SEO**: Canonical mit `?seite=n` und `rel=prev/next` bei blätternden Beitragslisten; Druckstile.
|
||||
- **Editor**: Vorschau mit eigenem CSS, Bereichs-/Sektionsstilen und Formularen; Kontaktdaten, Formular, verknüpfte Sektion und Embed direkt nach dem Einfügen gültig (leer = nichts gewählt); bereinigter Rich Text sofort im Editor (`EditorHistory.adopt()`).
|
||||
- **Kleines**: `legal.label` begrenzt, Docblocks in `PageController` gerückt.
|
||||
|
||||
**Geprüft:** neue `tests/BacklogTest.php` (7 Tests, 55 Zusicherungen: Archiv mit Fuß, Sektion, Nach-oben, Canonical/prev/next, Kategorie mit BreadcrumbList; blätternde Liste auf einer Seite; Formular im Fuß samt 422-Wiederanzeige; Sektion zweimal verknüpft; leere Bausteine; dunkle Palette; Sperre und .htaccess-Zusammenführung). Bestehende Tests angepasst: leerer Kontaktblock ist gültig, Schrittfolge des Updaters mit „Sperre“, `ReleaseOnDisk` kennt `toFile()`. Komplette Kette grün: `phpunit` 571 Tests (PHP 8.4), `npm test` 34, Twig-Lint mit `plugins/`, php-cs-fixer auf allen geänderten Dateien, `npm run test:editor` bestanden (Vorschau mit Formularen und eigenem CSS), DEEG `npm run test:browser` bestanden (Startseite 168 KB, 11 Anfragen – die vier Optionsbilder des Preisfinders kamen dazu), Lighthouse `/` Desktop überall 100, mobil Leistung 99 (die vier Piktogramme laden mit; Rest 100). Nicht committet.
|
||||
|
||||
## Arbeitspaket H zu Ende: Container, Einstellungen, Adressen, Vorlagen, Designpakete (16.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Dann mach H.“ Zweiter Teil des Pluginsystems (ADR 103). Grundentscheidung: ein Plugin bleibt Manifest plus Twig, ohne PHP – deshalb keine Hooks und keine eigenen Tabellen, dafür alles, was sich ohne Installationscode sicher übergeben lässt.
|
||||
|
||||
**Gebaut.** `PluginRegistry` liest jetzt auch `settings` (Schema wie Eigenschaften, gespeichert in der neuen Spalte `plugin.settings`, Migration `20260916000100`), `container`/`children` am Baustein, `routes` (Pfad wie eine Seite, Vorlage, Antwortart html/json/text/xml, Kernadressen gesperrt), `presets` (Bausteinlisten bis 64 KiB) und `theme` (Palette, Schriften, Grundwerte, Buttons). `block.html.twig` rendert die Kinder eines Containers selbst und reicht sie der Vorlage als `children`, dazu `settings`; `DocumentValidator` weist Kinder in Nicht-Containern und nicht erlaubte Kinder ab. `PageController::page()` beantwortet eine Plugin-Adresse, wenn keine Seite den Pfad hat (vor Weiterleitung und 404), mit Einstellungen und gesäuberten Abfrageparametern, Schutzkopfzeilen, ohne Skript, außer html mit `noindex`. Editor: `registerPlugins(list, presets)` kennt Container und erlaubte Kinder (`canContain`), `insertPreset('plugin:…')` fügt Vorlagen mit frischen Kennungen ein, der Werkzeugkasten zeigt sie hinter den eingebauten. Bildschirm „Plugins“: Einstellungsformular je aktivem Plugin, Adressen verlinkt, „Designpaket übernehmen“ (`Theme::save()`, Inhalte bleiben), beide Wege in der Rollenmatrix. Referenz: `plugins/vergleich` 1.1.0 (Einstellungen, Vergleichsgalerie als Container nur für Bildvergleiche, Vorlage „Vorher-Nachher-Abschnitt“, Adresse `/vergleich/daten`), neu `plugins/nachtblau` als reines Designpaket. Alle fünf lokalen Installationen migriert. `docs/PLUGINS.md` beschreibt das vollständige Manifest.
|
||||
|
||||
**Geprüft:** `tests/PluginTest.php` jetzt 8 Tests: Einstellungen (Vorgaben, Umwandlung aus dem Formular, Abweisung, Wirkung in Vorlage und Adresse, 404 bei ausgeschaltetem Plugin), Container (Kinder gerendert, Stylesheet einmal, fremdes Kind und Kinder im Nicht-Container abgewiesen, Editor-Definitionen und Vorlagen), Designpaket (übernommen, Buttons, Dokument unverändert, nur eingeschaltet, Fehler ohne Paket). `editor-model.test.mjs` prüft registrierte Container, Vorgaben, `canContain` und Vorlagen mit frischen Kennungen (34 grün). `EditorSecurityTest` mit den neuen Routen grün. `npm run test:editor` um Einstellungen speichern, die Adresse `/vergleich/daten`, die Plugin-Vorlage im Werkzeugkasten und den Container erweitert (Bildvergleich landet in der Galerie, ein Text daneben) und bestanden; ein Lauf davor blieb beim Neuladen des Editors bei „Wird geladen“ hängen, der nächste lief ohne Änderung durch – der Check protokolliert jetzt jede Konsolenfehlermeldung, damit so etwas beim nächsten Mal einen Grund hat. Komplette Kette grün: `phpunit` 565 Tests (PHP 8.4), `npm test` 34, Twig-Lint mit `plugins/`, php-cs-fixer auf allen neuen und geänderten Dateien. Nicht committet.
|
||||
|
||||
## Pluginsystem, erste Fassung – Manifest, Schalter, geprüfte Bausteine, Referenzplugin „Bildvergleich“ (15.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Plugin hätte ich gerne aber umgesetzt. Also Möglichkeit. … Dann Plugin so wie du es gesagt hast, hört sich gut an.“ Arbeitspaket H, erster Teil (ADR 102).
|
||||
|
||||
**Gebaut.** `src/Plugin/PluginRegistry.php` liest `plugins/<name>/plugin.json` (Titel, Fassung, `requires`, Bausteine mit Beschriftung, Symbol, Vorlage, Stylesheet, Skript und Eigenschaftenschema mit sieben Arten), meldet kaputte Manifeste mit Grund und hält den Schalter in der neuen Tabelle `plugin` (Migration `20260915000100`, in `TestSchema`). `DocumentValidator` prüft die Eigenschaften eines Plugin-Bausteins beim Schreiben gegen das Schema (`validate(..., writing: true)` an allen Schreibstellen), beim Lesen nicht – ein später eingeschaltetes Plugin macht keine Seite unlesbar. `templates/blocks/block.html.twig` rendert Bausteine aktiver Plugins über `@plugins/...` mit den erneut geprüften `props` (`src/Twig/PluginExtension.php`: `kant_plugin_block`, `kant_plugin_props`, `kant_plugin_media`); passt etwas nicht, bleibt der Kommentar. `PluginAssetController` liefert nur Dateien aktiver Plugins unter `assets/` mit erlaubter Endung; `PageController` verlinkt sie je Seite und öffnet die Richtlinie für ein Skript nur dann. Design-Tokens gelten jetzt auch für Plugin-Typen. Editor: der Dokumentaufruf liefert die Definitionen (`plugins`), `registerPlugins()` in `editor-model.js` trägt sie ein (Gruppe „Erweiterungen“, Vorgaben, Schema v4), `renderPluginFields()` in `editor.js` baut das Panel aus dem Schema, ein Bild über die Mediathek; die Vorschau lädt das Stylesheet, kein Skript. Verwaltung: Bildschirm „Plugins“ (Administrator) mit Ein/Aus, CSRF, in Navigation und Rollenmatrix. Release/Update: `plugins/` im Archiv und beim Archiv-Update Datei für Datei übernommen. Entwicklerdoku `docs/PLUGINS.md`.
|
||||
|
||||
**Referenzplugin `plugins/vergleich`:** Baustein `vergleich.bildvergleich` – zwei Bilder aus der Mediathek, ein Regler dazwischen (natives Bereichsfeld, Skript setzt nur `--pos` über das CSSOM), Beschriftungen, Startposition, Ecken. Ohne Skript steht der Vergleich bei der Startposition.
|
||||
|
||||
**Geprüft:** `tests/PluginTest.php` (5 Tests, 50 Zusicherungen): Manifest lesen und schalten (Schalter überlebt eine neue Registry), drei kaputte Manifeste mit Grund und ohne Schalter, Eigenschaftenprüfung nur bei aktivem Plugin und nur beim Schreiben, Rendering mit Stylesheet/Skript/CSP gegen Kommentar bei ausgeschaltetem Plugin, Asset-Route (Typ, `immutable`, 404 für Manifest, Vorlagen, `..` und ausgeschaltete Plugins), Design-Tokens und eigenes CSS am Plugin-Baustein. `EditorSecurityTest` mit den Plugin-Routen grün (38 Tests), `npm test` 33 grün, Twig-Lint aller Vorlagen einschließlich `plugins/`. Die vier lokalen Installationen sind migriert. `npm run test:editor` um ein Plugin-Szenario erweitert und bestanden: Bildschirm „Plugins“ mit axe (die Überschriftenfolge dort war zuerst falsch, h3 unter h1), Einschalten, Baustein aus dem Werkzeugkasten, Panel aus dem Schema, zwei Bilder über die Mediathek, Vorschau mit Stylesheet und ohne Skript, Speichern und Veröffentlichen, Website mit Skript und `script-src 'self'`, Ausschalten lässt den Baustein im Dokument und zeigt den Kommentar (`reports/editor/plugins.png`, `plugin-block.png`). Danach die komplette Kette: `phpunit` zuerst mit drei Befunden – der feste Twig-Pfad `paths` in `twig.yaml` brach jede Installation ohne Ordner `plugins/` (Installer-Tests), und die Vollständigkeitsprüfung des Archiv-Updaters kannte `plugins/` nicht. Jetzt registriert `PluginExtension` den Namensraum `@plugins` zur Laufzeit nur, wenn der Ordner existiert, und der Test führt `plugins/` als Datei-für-Datei-Ordner wie `public/`. Danach `phpunit` vollständig grün (562 Tests, 4664 Zusicherungen, PHP 8.4), Twig-Lint mit `plugins/`, php-cs-fixer auf allen neuen und geänderten Dateien.
|
||||
|
||||
**Nicht gebaut:** Container-Bausteine, eigene Routen und Einstellungen, Tabellen, Hooks, Ressourcen in der Verwaltung, Templates. Kein Sandkasten – Plugin-Vorlagen laufen mit den Rechten der Anwendung, so steht es auf dem Bildschirm. Nicht committet.
|
||||
|
||||
## Formulare mit Schritten, Bedingungen und Zusammenfassung – der DEEG-Preisfinder (15.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Plugins funktionieren noch nicht? Was fehlt denn? Ich hätte gerne kein Kontaktformular weiteres, sondern so ein ausgebautes Form.“ Dann: „Okay, dann erweitere den Kern.“ Gemeint ist der Preisfinder der laufenden DEEG-Website: Objektart wählen, Eckdaten je Objektart, Kontakt, Zusammenfassung.
|
||||
|
||||
**Kern (ADR 101):** Drei neue Feldtypen – Zahl (Unter-/Obergrenze, Einheit), Auswahl als Optionsfelder, Mehrfachauswahl. Ein Feld „Neuer Schritt“ eröffnet einen Abschnitt (Beschriftung = Titel, Hilfetext = Einleitung). Jedes Feld und jeder Schritt kann eine Bedingung tragen: gilt nur, wenn ein früheres Auswahlfeld einen der genannten Werte hat. Einstellung „Zusammenfassung vor dem Absenden“. Höchstens 40 statt 25 Felder. Der Server prüft die Bedingungen selbst (`FormRepository::check()`): ein Feld, das nicht galt, wird weder verlangt noch gespeichert, auch wenn der Browser einen Wert schickt; beim Speichern der Definition muss jede Bedingung auf ein früheres Auswahlfeld mit passenden Optionen zeigen. Bestehende Formulare bleiben ohne Migration gültig (`present()` ergänzt die Schlüssel).
|
||||
|
||||
**Öffentlich:** `templates/blocks/form.html.twig` rendert Schritte als `fieldset`/`legend`, Gruppen für Options- und Mehrfachauswahl, `type="number"` mit Grenzen und Einheit, Bedingungen als `data-show-if`/`data-show-values`. Ohne JavaScript steht das Formular vollständig auf der Seite. Neu `public/assets/form-steps.js` (nur auf Seiten, deren Formular es braucht, `FormRepository::needsScript()`; die CSP erlaubt es dann): Fortschritt, „Zurück“/„Weiter“ mit Browserprüfung je Schritt, Felder hinter nicht zutreffenden Bedingungen deaktiviert statt nur versteckt, Zusammenfassung als letzter Schritt mit „Ändern“, nach einer Serverablehnung der erste Schritt mit Fehler. `site.css`: Stile für Schritte, Fortschritt, Gruppen, Zahl mit Einheit, Zusammenfassung; `.block-form [hidden]` schlägt jetzt `display:grid`/`inline-flex`. E-Mail: Schritte als Zwischenüberschrift, Felder, die nicht galten, bleiben weg.
|
||||
|
||||
**Bildauswahl (Nachtrag):** Umut meinte mit „Upload“ eine Bildauswahl wie im Solar-Rechner von Elektro Bernhard (Bildkarten statt Kästchen). Optionsfelder und Mehrfachauswahl tragen jetzt je Option ein Bild aus der Mediathek; sobald eine Option eines hat, wird die Auswahl auf der Website zu anklickbaren Karten mit Bild und Text, das Kästchen bleibt darin für Tastatur und Vorlesesoftware. Im DEEG-Preisfinder hat die Objektart vier Piktogramme (`var/immobilien-deeg-assets/option-*.png`, mit GD gezeichnete Platzhalter, in der Mediathek gegen Fotos tauschbar). Test `testAChoiceWithPicturesBecomesClickableCards`, dazu zwei abgelehnte Zuordnungen.
|
||||
|
||||
**Verwaltung:** Feldkarte mit Untergrenze/Obergrenze/Einheit (bei Zahl), Optionen bei Liste/Optionsfeldern/Mehrfachauswahl, „Bild je Option“ als Liste der Mediathek, Bedingung („Nur zeigen, wenn das Feld … einen dieser Werte hat“); Zustellung mit Schalter „Zusammenfassung vor dem Absenden zeigen“; Vorschau zeigt Schritte, Optionsfelder, Kästchen, Einheit und Bedingungen. Alles ohne Skript bedienbar.
|
||||
|
||||
**DEEG:** Das Formular `bewertung` im Aufbauskript ist jetzt der Preisfinder: Schritt 1 Objektart (Wohnung, Haus, Mehrfamilienhaus, Grundstück) und Anlass (Verkaufen, Vermieten); Schritt 2 Eckdaten je Objektart – Wohnfläche, Grundstücksfläche, Zimmer, Wohneinheiten, Baujahr, Zustand, Einbauküche, Aufzug, Parkmöglichkeit, Bebaubarkeit, jeweils hinter Bedingungen; Schritt 3 Lage und Kontakt; dann die Zusammenfassung. Die Feldliste folgt dem Preisfinder von immobiliendeeg.de (Wohnung-Seite, 15.09.2026). Das Kontaktformular bleibt, wie es war.
|
||||
|
||||
**Geprüft:** neue `tests/FormStepsTest.php` (5 Tests, 75 Zusicherungen): Normalisierung beim Speichern, zehn abgelehnte Definitionen mit Begründung, Bedingungen in `check()` (Grundstück ohne Wohnfläche, Wohnung mit Pflicht, Zahl mit Komma, Grenzen, fremde Optionen), Rendering samt Skript und CSP (und ein einfaches Formular weiter ohne Skript, `script-src 'none'`), POST mit Speicherung nur der geltenden Felder und 422-Wiederanzeige mit erhaltenen Kreuzen. Im Browser (Port 8094): drei Schritte durchgespielt, Felder je Objektart, Zusammenfassung mit allen Werten, Absenden mit Erfolgsmeldung, Einsendung in `form_submission` mit leeren Feldern für alles, was nicht galt; Grundstück zeigt nur zwei Eckdatenfelder, „Weiter“ hält bei leerem Pflichtfeld. Komplette Kette: `phpunit` 556 grün (PHP 8.4), `npm test` 33 grün, `npm run test:editor` vollständig bestanden (darunter die axe-Prüfung der neuen Formularverwaltung), Twig-Lint ohne Befund, php-cs-fixer auf den geänderten Dateien (die vier Befunde in `install.php` und den drei älteren Aufbauskripten sind vorbestehend). DEEG nach dem Umbau: `npm run test:browser` bestanden (Startseite jetzt 2 Skripte – Einblendungen und Formular –, 161 KB), Lighthouse `/` mobil und Desktop weiterhin überall 100.
|
||||
|
||||
**Browsercheck angepasst:** `scripts/browser-check.mjs` erlaubte bisher höchstens ein Skript je Seite. Das war schon vor dem Formularskript zu eng – Slider, Reiter, Einblendungen, Bildbetrachter und Nach-oben sind je eine eigene Datei –, fiel aber erst jetzt auf, weil keine Referenzseite zwei davon lud. Neu: höchstens drei Skripte, alle aus `/assets/` des eigenen Ursprungs, und weiterhin keins, wenn die Richtlinie keins erlaubt.
|
||||
|
||||
**Nicht gebaut:** Bedingungen auf Zahlenbereiche oder mehrere Felder, Dateiuploads, Schritt-für-Schritt ohne JavaScript, Formulare in der Editorvorschau. Nicht committet.
|
||||
|
||||
## DEEG Immobilien & Hausverwaltung nachgebaut, Anfrage als Formular (15.09.2026)
|
||||
|
||||
**Anlass:** Umut lieferte `Immobilien-DEEG-Website.zip` (Onepager für die Deeg Hausverwaltung und Immobilien GmbH, Freudenstadt) und wollte ihn „nach dem Prinzip wie die anderen Seiten mit Kontaktformular“ aufbauen; dazu die Frage, ob die Bewertungsanfrage ein eigenes Plugin braucht.
|
||||
|
||||
**Antwort auf die Plugin-Frage:** Nein. Ein Plugin ist in diesem CMS ein Baustein mit Namensraum, den der Kern aufbewahrt, aber nicht rendert (ADR zum Erhaltungsvertrag, ADR 049); eine Laufzeit dafür gibt es erst mit Phase 4. Die Anfrage ist deshalb das Formular `bewertung` (Auswahl Wohnung/Haus/Mehrfamilienhaus/Grundstück – die vier Objektarten des Preisfinders der laufenden Website –, Straße und Ort, Name, E-Mail, Telefon, Hinweise, Einwilligung), der Kontakt das Formular `kontakt` (mit Auswahl Verkauf/Vermietung/Verwaltung). Beide verschicken serverseitig und liegen unter Formulare in der Verwaltung; die Vorlage verlinkte dafür noch auf www.immobiliendeeg.de.
|
||||
|
||||
**Nachbau (`scripts/seed-immobilien-deeg.php`, `scripts/reset-immobilien-deeg.sh`).** Fünfte Installation `var/immobilien-deeg.db`, `var/immobilien-deeg-media`, Bildquellen `var/immobilien-deeg-assets` (Logo, zwei Architekturfotos aus dem Paket), Startkonfigurationen `immobilien-deeg` (Port 8094) und `immobilien-deeg-prod` (Port 8095, Produktionsmodus zum Messen). Startseite mit Hero (Foto, Schild, Bildzeile, blauer Aufruf zur Bewertung), Leistungsleiste, drei Leistungskarten mit Akkordeon, blauem Bewertungsabschnitt mit Formularkarte, Über uns mit „SEIT 1994“, Angebotsband (Link auf www.immobiliendeeg.de/immobilien/, die Immobilien-Datenbank ist nicht Teil des Pakets), Kontakt mit Telefon, E-Mail, Adresse, Bürozeiten und Formularkarte; Kopf mit heller Leiste, Logo, Menü und blauem Knopf „Immobilie bewerten“; Fuß mit Logo, Rechtszeile und Agenturlink. Impressum mit Register und Vertretung vom Impressum der laufenden Website (HRB 430747, Amtsgericht Stuttgart, Furkan Kurt); USt-ID, Aufsicht nach § 34c GewO und Bildlizenz des übernommenen Fotos als „NOCH ZU PRÜFEN“. Datenschutz als eigene Fassung ohne Cookies und Fremddienste. Schrift Arial wie die Vorlage, nichts zu holen.
|
||||
|
||||
**Für 100 geändert, nicht für die Optik:** Blau als Fläche mit weißer Schrift trägt `#0076b6` (4,9:1 statt 4,2:1 beim Logo-Blau), kleine blaue Schrift auf Weiß `#006aa3`; das Logo-Blau `#0081c7` bleibt für Linien und große Überschriften. Der Button-Baustein hängt immer „↗“ an, die Vorlage kommt ausdrücklich ohne Pfeile aus – am Button `& span{display:none}`. Die Copyright-Zeile trägt ihre Farbe selbst, weil der Fuß kleinen Text sonst auf 3,8:1 aufhellt.
|
||||
|
||||
**Browsercheck angepasst (`scripts/browser-check.mjs`):** Vor Überlauf- und axe-Prüfung spielt das Skript alle laufenden Einblendungen zu Ende (`document.getAnimations().forEach(a => a.finish())`). Vorher hing das Ergebnis vom Zeitpunkt ab: mitten in einer Einblendung rechnete axe den Kontrast gegen die halb durchsichtige Farbe (drei Scheinbefunde am Hero), und ein von rechts hereingleitender Block zählte als horizontaler Überlauf.
|
||||
|
||||
**Geprüft:** `npm run test:links` gegen Port 8094: 3 Seiten, 14 Adressen, kein toter Verweis. `npm run test:browser` (`PHP_BINARY=C:/php84/php.exe`): sechs Seiten-/Viewport-Prüfungen mit axe, Tastatur, ohne JavaScript bestanden, keine redaktionellen Befunde; Ladebudget der Startseite 157 KB, 6 Anfragen, 11 Abfragen. Lighthouse 13.4.1 (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den Produktionsmodus auf Port 8095 für `/`, `/impressum`, `/datenschutz`: **Leistung, Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100** (Startseite LCP 1,8 s mobil / 0,5 s Desktop, CLS 0, TBT 0 ms); Berichte in `reports/lighthouse-immobilien-deeg/`. Kontaktformular einmal abgeschickt: Erfolgsmeldung, Eingang in `form_submission`. Screenshots in 1440 und 390 px gegen die Vorlage verglichen. php-cs-fixer auf dem Skript ohne Befund.
|
||||
|
||||
**Nicht geprüft:** der Editor auf den DEEG-Dokumenten im Browser, E-Mail-Zustellung (lokal `sendmail://default`, der Eingang bleibt trotzdem gespeichert), Firefox/Safari, ein echtes Touchgerät, das Hosting. Nicht committet.
|
||||
|
||||
## 1.17.0 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.17.0` (Commit `d07154e`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.17.0 – Adresse mit oder ohne www aus `SITE_ORIGIN`, Rollen wie die Rollenhilfe sie verspricht, Befunde der Gesamtprüfung (ADR 099, ADR 100).
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.17.0 v1.17.0`, `php scripts/build-release.php` darin): 3555 Dateien im Archiv, 4,7 MB.
|
||||
|
||||
**Anhänge:** `kantsites-1.17.0.zip` (SHA-256 `d421654cd6803e3a683fbbd893db22c005e96261da4f64892c4ae1bb868557f6`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.17.0.py`, erst Entwurf, dann veröffentlicht. Die Releasenotes (`var/release-notes-1.17.0.md`) nennen die Rollenänderung als ersten Punkt vor dem Update.
|
||||
|
||||
**Geprüft:** HTTP-Archiv-Update 1.16.1 → 1.17.0 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`, aus dem Hauptrepository heraus); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.17.0`. Die Prüfungen des Codes stehen im Eintrag darunter.
|
||||
|
||||
**Nächster Schritt:** auf `web.kant-it.de` und dem Drews-Hosting über System → Update aus dem Archiv einspielen (keine Migration), danach unter System die eingetragene Adresse prüfen (`SITE_ORIGIN` mit oder ohne www, wie die Website heißen soll) und die Rollen der vorhandenen Konten unter Benutzer durchsehen – Redakteure sehen Design, Einstellungen, Bereiche, Menüs und Formulare nicht mehr.
|
||||
|
||||
## 1.17.0 – Gesamtprüfung, Adresse mit oder ohne www, Rollen wie versprochen (15.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Komplette Analyse – Bugs, Durchgängigkeit, ob alles sauber ist; was man besser machen kann; und: die URL soll aus env kommen, www oder ohne, die Seite soll darauf reagieren.“ Nachgeschoben: „prüfst du auch, ob man ohne Admin etwas verändern kann?“
|
||||
|
||||
**Prüfung.** Sechs getrennte Durchsichten: Backend (Controller, Content, Security, Twig), Betrieb (Ops, Commands, Subscriber, Config, Installer, CI), Editor (JS, Admin-Templates, Modelltests), öffentliche Ausgabe (Templates, CSS, Skripte, SEO, CSP, Einwilligung), Dokumentation und Repository (README, docs, Skripte, Ports, Versionen, git-Hygiene) sowie eine Routenmatrix aller 63 Verwaltungs- und 15 öffentlichen Routen mit Firewall, Rolle, CSRF und Testabdeckung. Vorab: `phpunit` 512 grün, `npm test` 33 grün, Twig/YAML/Container-Lint ohne Befund, php-cs-fixer eine vorbestehende Zeile in `CmsTest`. Repository-Hygiene in Ordnung (313 Dateien, keine Geheimnisse, `var/`, `reports/`, `.env` ignoriert, `VERSION` = Tag = STATUS).
|
||||
|
||||
**Antwort auf die Rollenfrage:** Ohne Anmeldung kann niemand Inhalte, Einstellungen oder Konten ändern – alle Verwaltungsrouten liegen hinter der Firewall, jede schreibende prüft Rolle und CSRF-Token. Zwei echte Lücken gab es trotzdem, beide behoben: (1) Der Installer öffnete sich wieder, sobald die Datenbank nicht antwortete (Wartung beim Hoster, gedrehtes Passwort) – dort hätte jeder eine eigene Datenbank samt Administratorkonto eintragen können. (2) Die Rolle Redakteur durfte Design, Einstellungen (Startseite, `robotsExtra`, Turnstile, Einwilligung), Bereiche, Menüs und Formulare samt Empfängeradresse live ändern, entgegen der Rollenhilfe. ADR 100.
|
||||
|
||||
**www / SITE_ORIGIN (ADR 099):** `CanonicalHostSubscriber` leitet die jeweils andere Schreibweise mit 301 auf die eingetragene Adresse um (Pfad und Abfrage bleiben, Schema und Port aus `SITE_ORIGIN`, auch für die Verwaltung); fremde Hosts, IP-Adressen und localhost bleiben erreichbar, nur GET/HEAD, kein HTTPS-Zwang. Neu `php bin/console cms:site:origin <adresse>`; der Bildschirm „System“ zeigt Adresse, Quelle und einen Hinweis bei abweichendem Host. Der www-Block in `public/.htaccess` ist weg, `.env.example` erklärt die Einstellung, LIVEGANG Punkt 3 und README angepasst.
|
||||
|
||||
**Behoben (Auswahl, alles mit Test):** Sitzungscookie folgt `ADMIN_PATH` (Anmeldung nach `cms:admin:path` war unmöglich); `%env(resolve:)%` gegen prozentkodierte Passwörter durch `projectdir:`-Prozessor ersetzt; Einwilligung nur von dieser Website und kein `/\`-Rücksprung; JSON-LD mit `JSON_HEX_TAG`; Komma im eigenen CSS; Skriptprüfung beim Wiederherstellen einer Fassung; keine Unterseite unter einer Seite im Papierkorb; geplante Veröffentlichung blockiert bei einem Fehler nicht die anderen; Archiv escaped alte Bereiche; Feed-Zeitzone UTC; Sitemap-`lastmod` aus der Fassung; Vollbild in Embeds; Tabs/Bildbetrachter in Bereichen und Sektionen; Schatten bei Überfahren/Scrollen erbt Form; Download nur über HTTPS-Weiterleitungen; Composer/Git-Umgebung und PHP-Kommandozeile im Befehlsläufer; Updater fängt jeden Fehler. Editor: Schemafassung für Menü/Beitragsliste/Slider, gesperrte Design-Knöpfe bleiben gesperrt, Ausrücken/Baum-Zug mit Regeln, kein leerer Verlaufsschritt bei Ablehnung, Bild auf Akkordeon meldet sich, Klartext unter v12, Zahlenfelder ab 0, Farbwähler ohne `onComplete`. Docs: `.env.local`-Reste, `motion`/`archive-update` als npm-Skripte, README-Einleitung und dritte Referenzwebsite, UPDATE.md, `install.php` erkennt `.env`, Release-Builder ohne `LICENSE`.
|
||||
|
||||
**Geprüft:** `phpunit` vollständig mit PHP 8.4 grün (neu: `CanonicalHostTest` 24 Fälle, `SiteOriginCommandTest`, `ProjectDirEnvProcessorTest`, Rollen-Matrix und Cookie-Pfad in `EditorSecurityTest`, Installer-Fälle in `InstallationTest`, Systembildschirm, Tabs im Fuß, Sitemap-`lastmod`); `npm test` 33 grün (Fassungstabelle angepasst); Twig/YAML/Container-Lint ohne Befund; php-cs-fixer ohne Befund; `cms:health` gegen die Entwicklungsdatenbank mit dem neuen Prozessor `OK`; `npm run test:editor` (Playwright + axe, `PHP_BINARY=C:/php84/php.exe`) vollständig bestanden – ein erster Lauf scheiterte an einer seit 1.16.1 veralteten Zusicherung des Skripts („Spalte in der Sektion weiterhin gesperrt“), die jetzt die erlaubte Spalte erwartet. Nicht geprüft: Firefox/Safari, ein echtes Touchgerät, das Hosting, ein echter MySQL-Server.
|
||||
|
||||
**Rückstand (aus der Prüfung, nicht umgesetzt):** Archivseiten auf die Aufbaukette der Seiten heben (Kopf/Fuß-Stile, Schriften, Sektionen, Nach-oben); Formulare, Brotkrumen und Beitragslisten in Bereichen/Sektionen sind dort tot; doppelte Block-IDs bei mehrfach verknüpfter Sektion; Breakpoints 650 vs. 767 px vereinheitlichen; feste Farbwerte (`.tone-ink`, Skip-Link, CTA-Button, Fehlerrot, `color-scheme`) bei dunklen Paletten; MySQL ohne Sicherung, Archiv-Update und Rollback; Archiv-Update überschreibt `.htaccess` (HTTPS-Schalter); Release-Caches wachsen; Rollback-Kopie des Git-Updaters ohne Sperre; Archiv komplett im Speicher; Canonical/`rel=prev/next` blätternder Beitragslisten; Druckstile; Editor-Vorschau ohne eigenes CSS/Bereichsstile/Formulare; Skripte in Bereichen laufen nie; vier Blocktypen direkt nach dem Einfügen ungültig (Kontakt, Formular, Sektion, Embed); Rich-Text-Normalisierung erst nach Neuladen sichtbar; `legal.label` unvalidiert; verrutschte Docblocks; CI-`mysql`-Job ohne Tests; Ports 8090/8092 in QUALITY/STATUS sind Messserver, nicht die Startkonfigurationen.
|
||||
|
||||
**Veröffentlicht als 1.17.0** (kein neues Dokumentschema, keine Migration; bestehende Redakteurskonten verlieren Design, Einstellungen, Bereiche, Menüs und Formulare – siehe Eintrag oben). **Nächster Schritt:** `SITE_ORIGIN` auf `web.kant-it.de` und dem Drews-Hosting prüfen und die HTTPS-Zeilen in `.htaccess` einschalten.
|
||||
|
||||
## 1.16.1 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.16.1` (Commit `937344e`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.16.1 – einzelne Spalte in einer Sektion, Ausrichtung für Buttons, breite und volle Abschnitte gegen die Design-Inhaltsbreite, Nach-oben-Knopf in einer Landmarke, Cormorant Garamond lokal.
|
||||
|
||||
**Anlass:** Die neu aufgebaute Drews-Datenbank trägt das Token „Ausrichtung“ an Buttons; unter dem auf dem Hosting laufenden 1.16.0 lehnte der Validator diese Dokumente beim Rendern ab (500 auf jeder öffentlichen Seite, Verwaltung lief). Das Release bringt Code und Datenbank wieder zusammen.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.16.1 v1.16.1`, `php scripts/build-release.php` darin): 3552 Dateien im Archiv, 4,7 MB.
|
||||
|
||||
**Anhänge:** `kantsites-1.16.1.zip` (SHA-256 `2922027abb648839b89962351aa3dbeaa9e354dd985c94a7a2efddabb86d6731`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.16.1.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** HTTP-Archiv-Update 1.16.0 → 1.16.1 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.16.1`. Die Prüfungen des Codes stehen in den beiden Einträgen darunter.
|
||||
|
||||
**Nächster Schritt:** auf dem Drews-Hosting über System → Update aus dem Archiv einspielen (keine Migration), danach `var/cache/prod` prüfen; die kopierte Datenbank kann bleiben.
|
||||
|
||||
## Einzelne Spalte in einer Sektion, Buttons ausrichten (15.09.2026)
|
||||
|
||||
**Anlass:** Umut konnte im Editor keine Spalte direkt in eine Sektion legen (ausgegraut) – so zentriert er einen Button –, und der Button „Alle Mitteilungen“ auf der Drews-Startseite stand links, ohne eine Option dafür.
|
||||
|
||||
**Editor (`editor-model.js`):** `REQUIRE.column` ist jetzt `['row', 'section']`. Eine einzelne Spalte darf direkt in einer Sektion stehen (schmaler Inhalt, zentrierte Elemente), in einer Spalte oder lose im Dokument weiter nicht. Test angepasst.
|
||||
|
||||
**Buttons (`design-tokens.json`, `site.css`, `design.css` neu gebaut):** Das Design-Token „Ausrichtung“ gilt jetzt auch für Buttons. Weil ein Button `inline-flex` ist, zentriert ihn `margin:auto` nicht; `site.css` macht ihn bei `align-center`/`-right` zum Block mit eigener Breite (`display:flex;width:fit-content;margin-inline:auto`, dazu `justify-self` für Raster), je Gerät an den Breakpoints von `design.css` (1024/650 px). Das Drews-Aufbauskript nutzt das Token für alle mittigen Buttons statt der wirkungslosen auto-Ränder.
|
||||
|
||||
**Geprüft:** `npm test` 33 grün (darunter der geänderte Einfügetest), PHP-Tests zu Design-Tokens 11 grün; Drews-Startseite nach Neuaufbau: „Alle Mitteilungen“ und „Alle Projekte ansehen“ mittig, Aufruf-Buttons mittig, auf 1440 und 390 px. Unveröffentlicht, gehört mit den beiden Korrekturen darunter in 1.16.1.
|
||||
|
||||
## Ihr Malerservice Drews nachgebaut, 15.09.2026
|
||||
|
||||
**Anlass:** Umut wollte maler-drews.com (Flatsome-Auftritt, Malermeister Axel Drews, Gernsbach) nach dem bekannten Prinzip in eigener Installation nachbauen – exakt im Viewer, mit Lighthouse, axe und maschinenlesbaren Ausgaben auf 100.
|
||||
|
||||
**Vorlage ausgelesen, nicht geschätzt.** Alle 9 Seiten und 31 Beiträge der laufenden Website als HTML geholt, Struktur, eigenes CSS (`malerdrews-*`, `drews-*`), Abschnittsfarben und Inline-Werte extrahiert, Screenshots in 1440 und 390 px als Vergleich. Befund: Lato + Cormorant Garamond 700, Gold `#c6a36b`, Creme `#f7f5f2`, dunkle Bänder `#232323`/`#111`/`#0a0a0a`, Kopf 90 px weiß mit Versalmenü, Startbild als Slider mit drei Folien und goldener Welle, Kartenraster aus Bild und Verlauf, Zickzackliste der Projekte, Zeitleiste der Mitteilungen. `/about-us` und `/innenaustattung` sind auf der Vorlage Lorem-ipsum-Reste, `/little-greene` ein 404.
|
||||
|
||||
**Nachbau (`scripts/seed-maler-drews.php`, `scripts/seed-maler-drews-inhalte.php`, `scripts/reset-maler-drews.sh`).** Eigene Installation `var/maler-drews.db`, `var/maler-drews-media`, Bildquellen `var/maler-drews-assets` (139 Bilder, auf 2400 px verkleinert, Zuordnung in `manifest.json`), Startkonfiguration `maler-drews` auf Port 8093. Acht Seiten (Start, Aktuelles, Unsere Projekte, Kunstmalerei, Kontakt, Impressum, Datenschutz, 404), 31 Beiträge mit Bildkopf, Text, Galerie mit Bildbetrachter, Nachbarn und Aufruf, Kategorien „Aktuelles“ (5) und „Projekte“ (26); die Listen sind Beitragslisten-Bausteine (Karten auf der Startseite, Liste als Zeitleiste, Wechselseitig als Zickzack). Kopf mit Marke und Versalmenü, Fuß in vier Spalten mit Instagram, Öffnungszeiten und Rechtszeile. Formular serverseitig, Google-Karte und elf Videos als eingebettete Inhalte hinter der Einwilligungsleiste. Impressum und Datenschutz wörtlich übernommen (die Datenschutzerklärung nennt Google Analytics und reCAPTCHA, die der Nachbau nicht nutzt – vor dem Livegang zu bereinigen). Weiterleitungen für die drei toten Vorlagenadressen und die eine über 80 Zeichen lange Beitragsadresse. Bewusste Abweichungen stehen im Kopf des Skripts.
|
||||
|
||||
**Für 100 geändert, nicht für die Optik:** Gold auf Weiß erreicht 2,4:1 – Kleinzeilen, Datum, Links und aktive Menüpunkte auf hellem Grund tragen `@gold-text` (`#8a6a2f`, 5,0:1), Buttons dunkle statt weiße Schrift auf Gold (6:1), der Einwilligungsknopf ebenso; vor den Beitragslisten steht eine nur für Vorlesesoftware sichtbare H2; der Copyright-Link ist unterstrichen. Das Startbild wird über den äußeren Abschnitt vorgeladen (nur das erste Hintergrundbild einer Seite bekommt ein `preload`).
|
||||
|
||||
**Zwei Lücken im CMS behoben (`site.css`, `page.html.twig`, unveröffentlicht, gehört in 1.16.1):** Abschnitte mit Breite „breit“ oder „voll“ blieben so schmal wie alle anderen, weil `.kant .shell{max-width:var(--k-container)}` des Designs die Breitenregeln schlug – jetzt mit `.kant` davor. Der Nach-oben-Knopf stand außerhalb jeder Landmarke (axe „region“) und steckt jetzt in einem `nav`. Beim Bauen gelernt: eigenes CSS erlaubt kein `@media`, keine Anführungszeichen, keine Klammern in Selektoren (`:not()`, `nth-child()`), keine Kommaselektoren, und eine leere `&{…}`-Regel geht verloren – Eigenschaften des Elements ohne `&{}` schreiben.
|
||||
|
||||
**Geprüft:** Lighthouse 13.4.1 (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus (`APP_ENV=prod`, Port 8092) auf sechs Adressen (`/`, `/aktuelles`, `/unsere-projekte`, `/kunstmalerei`, `/kontakt`, `/friedrichsbad-baden-baden`): **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung 98–100 (Startseite 99 mobil / 98 Desktop, Kunstmalerei 99/99, Projekte und Beitrag 99 mobil, sonst 100; LCP ist jeweils das Foto hinter dem Seitenkopf, mobil 1,6–2,1 s, Desktop 0,6–1,0 s; CLS ≤ 0,01, TBT ≤ 7 ms). Berichte in `reports/lighthouse-maler-drews/`. Drei Messrunden davor zeigten Barrierefreiheit 91–93 (Gold-Kontrast, Überschriftenfolge, Link nur durch Farbe), dann 96–100 (aktiver Menüpunkt, Formularbeschriftungen), jetzt 100. axe (WCAG 2.0/2.1 A+AA und Best Practice, Playwright, 1440 und 390 px, nach dem Durchscrollen) auf neun Seiten einschließlich Impressum und Datenschutz: **null Verstöße** (`var/drews-axe.mjs`); vorher 58, davon 36 Kontrast, 18 „region“ am Nach-oben-Knopf, 2 Überschriftenfolge, 2 Link im Text. Maschinenlesbar: `llms.txt` mit allen Seiten und Beiträgen, `sitemap.xml` mit 41 Adressen, `robots.txt`, `feed.xml`, `LocalBusiness` mit Adresse, Öffnungszeiten, Einsatzgebiet und Instagram als `sameAs`. Alle 41 Adressen der Vorlage antworten mit 200 oder 301. Screenshots des Nachbaus in 1440 und 390 px gegen die der Vorlage verglichen (Startseite, Aktuelles, Projekte, Kunstmalerei, Kontakt, Beitrag, Impressum). PHP-Tests 512 grün (PHP 8.4), `npm test` 33 grün, Twig-Lint, php-cs-fixer auf den neuen Skripten.
|
||||
|
||||
**Nicht geprüft:** der Editor auf den Drews-Dokumenten im Browser (kein Anmelden mit Passwort in dieser Sitzung), Firefox/Safari, ein echtes Touchgerät, das Hosting. Die Videos liegen weiter auf maler-drews.com und brauchen für den Livegang einen eigenen Speicherort.
|
||||
|
||||
## 1.16.0 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.16.0` (Commit `32ee288`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.16.0 – Spaltenbreiten einer Zeile durch Ziehen im Editor.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.16.0 v1.16.0`, `php scripts/build-release.php` darin): 227 Quelldateien, `vendor/` ohne Entwicklungspakete, 3549 Dateien im Archiv.
|
||||
|
||||
**Anhänge:** `kantsites-1.16.0.zip` (4,7 MB, SHA-256 `8aa8d40d79dfe7bf972ca005ed92d688cf63beb93a4807c20d5dcabb9b2f9264`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.16.0.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** HTTP-Archiv-Update 1.15.1 → 1.16.0 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.16.0`. Die Prüfungen des Codes selbst stehen im Eintrag darunter.
|
||||
|
||||
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv (keine Migration), dann im Editor eine Zeile wählen und die Spaltengrenze ziehen – etwa bei einer Karte mit Symbol links und Text rechts.
|
||||
|
||||
## 1.16.0 – Spaltenbreiten einer Zeile durch Ziehen (15.09.2026)
|
||||
|
||||
**Anlass:** Umut, mit der Karte „HausKlar“ (Symbol links, Text rechts) als Beispiel: „bei Rows die Breite ändern, am besten per Drag and Drop im Editor.“ Zeilen kannten bisher nur gleich breite Spalten. Entscheidung und Begründung: ADR 098.
|
||||
|
||||
**Umsetzung:** Neue freie Stileigenschaft `columnWidths` an der Zeile (Typ `fractions`, `StyleSchema::FRACTIONS`, z. B. `66.7 33.3`), je Gerät, ohne neues Dokumentschema. `StyleCompiler::columnTracks()` macht daraus `grid-template-columns:minmax(0,66.7fr) minmax(0,33.3fr)`, füllt fehlende Anteile mit dem Durchschnitt auf, kappt überzählige und setzt ein Gerät, dessen Aufteilung stapelt (`one` oder `gridColumns` 1), auf eine Spur – sonst schlüge die ID-Regel des Desktops die Stapel-Klasse des Handys. Im Editor (`canvas-overlay.js`) liegt auf jeder Grenze zwischen zwei nebeneinander stehenden Spalten der gewählten Zeile (oder der Zeile um die gewählte Spalte) ein Griff `.ov-resize`: Ziehen stellt die Zeile in der Vorschau live um, Loslassen schreibt einen Verlaufsschritt (`onResize` in `editor.js` → `setStyle`), Pfeiltasten verschieben um fünf Punkte und behalten den Fokus. Ausgangswert sind die gemessenen Spaltenbreiten. Im Design-Bedienfeld gibt es dazu das Textfeld „Spaltenbreiten“ (Layout, nur bei Zeilen). Kein Griff bei gestapelten Spalten und während ein Element gezogen wird.
|
||||
|
||||
**Geprüft:** `phpunit` vollständig mit PHP 8.4 512 von 512 grün, mit PHP 8.2 512 grün mit der vorbestehenden Warnung in `UpdaterTest`; neu `tests/ColumnWidthsTest.php` (Spuren, Stapeln je Gerät, Auffüllen und Kappen, Ablehnung ungültiger Werte). `npm test` 33 grün (Relevanz nur für Zeilen). `npm run test:editor` mit `PHP_BINARY=C:/php84/php.exe` vollständig bestanden – neu darin: echter Mauszug am Griff verbreitert die erste Spalte, der Wert steht im Bedienfeld der Zeile, auf „Mobil“ gibt es keinen Griff, ein Rückgängig stellt gleiche Spalten her (`reports/editor/column-widths.png`). php-cs-fixer ohne Befund. Ein erster Lauf des Checks scheiterte an `openGroup`, das im Skript erst später definiert ist – die Gruppe wird jetzt an Ort und Stelle geöffnet.
|
||||
|
||||
**Nebenbei:** `.claude/launch.json` trägt in der Arbeitskopie den noch nicht committeten Eintrag `maler-drews` einer anderen Sitzung auf Port 8091; die Pilotkonfiguration ist deshalb lokal auf 8092 verschoben, die Datei bleibt aus diesem Commit heraus.
|
||||
|
||||
## 1.15.1 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.15.1` (Commit `d05dba3`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.15.1 – eigene Absenderadresse je Formular.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.15.1 v1.15.1`, `php scripts/build-release.php` darin): 227 Quelldateien, `vendor/` ohne Entwicklungspakete, 3549 Dateien im Archiv.
|
||||
|
||||
**Anhänge:** `kantsites-1.15.1.zip` (4,7 MB, SHA-256 `e15a7a69f299c16d86d372b65610b52f15b40b93904fa6402ea6991de7ed21f7`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.15.1.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** HTTP-Archiv-Update 1.15.0 → 1.15.1 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.15.1`. Die Prüfungen des Codes selbst stehen im Eintrag darunter.
|
||||
|
||||
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv (keine Migration), dann unter Formulare eine Absenderadresse der eigenen Domain eintragen und eine Testanfrage senden.
|
||||
|
||||
## 1.15.1 – Eigene Absenderadresse je Formular (15.09.2026)
|
||||
|
||||
**Anlass:** Umut: „Versandadresse bei Kontaktformularen sollte man noch festlegen können – aktuell wird's über die gleiche Adresse verschickt wie der Empfänger?" Stimmte: `FormController::deliver()` setzte `From` auf die Empfängeradresse. Das geht schief, sobald der Empfänger ein Postfach bei einem anderen Anbieter ist (z. B. eine GMX- oder Gmail-Adresse), weil viele Hoster nur zustellen, was von einer Adresse der eigenen Domain kommt, und SPF/DMARC des fremden Anbieters die Post als Fälschung abweisen.
|
||||
|
||||
**Umsetzung:** Neue Formulareinstellung `sender` (freiwillig). `FormRepository::validateSettings()` prüft sie wie den Empfänger mit `FILTER_VALIDATE_EMAIL`, leer bleibt erlaubt; die Verwaltung (`forms.html.twig`, Abschnitt Zustellung, Feld „Absender“ direkt unter dem Empfänger mit Hinweis) und `FormAdminController` reichen sie durch. Beim Versand gilt `sender ?: recipient` – bestehende Formulare ohne den Schlüssel verhalten sich wie bisher, es ist keine Migration nötig. Die Antwortadresse aus dem Formularfeld bleibt `Reply-To`.
|
||||
|
||||
**Geprüft:** `phpunit` vollständig mit PHP 8.4 509 von 509 grün, mit PHP 8.2 509 grün mit der vorbestehenden Warnung in `UpdaterTest`; `npm test` 33 grün; `npm run test:editor` mit `PHP_BINARY=C:/php84/php.exe` vollständig bestanden, darin die axe-Prüfung der Formularseite. Neu in `EditorSecurityTest`: `testAFormMaySendFromItsOwnAddress` (From = Absender, To = Empfänger, Reply-To = Feld; ungültiger Absender abgewiesen, Leerzeichen werden zu leer) und im bestehenden Absendetest die Prüfung, dass ohne Absender From = To ist – beides über `getMailerMessage()` mit dem `null://`-Transport der Tests. php-cs-fixer ohne Befund. In der Pilotkopie im Browser (`kantsites-pilot`, jetzt Port 8091, weil 8087 von einer anderen Sitzung belegt war): Feld sichtbar, `formular@kant-it.de` gespeichert und nach dem Neuladen erhalten, Meldung „Das Formular wurde gespeichert.“ Ein echter Versand über SMTP wurde nicht geprüft.
|
||||
|
||||
**Nächster Schritt:** Auf `web.kant-it.de` nach dem nächsten Update unter Formulare eine Absenderadresse der eigenen Domain eintragen (z. B. `formular@kant-it.de`), dann eine Testanfrage senden und im Postfach Absender und Zustellung kontrollieren.
|
||||
|
||||
## 1.15.0 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.15.0` (Commit `160c394`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.15.0 – Formulare, Statistik, Design und Seitenleiste der Verwaltung überarbeitet, Formulare mit eigenen Farben und Vorschau.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.15.0 v1.15.0`, `php scripts/build-release.php` darin): 227 Quelldateien, `vendor/` ohne Entwicklungspakete, 3549 Dateien im Archiv.
|
||||
|
||||
**Anhänge:** `kantsites-1.15.0.zip` (4,7 MB, SHA-256 `86ef728812db3b2f565f07dd1d8aebf8d5d41b75875695544134684b77a193ed`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.15.0.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** HTTP-Archiv-Update 1.14.2 → 1.15.0 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200; `scripts/archive-update-check.mjs`, aus dem Hauptrepository heraus, weil der saubere Checkout weder `vendor/` noch `var/kantit.db` hat); nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.15.0`, Installeradresse 200. Die Prüfungen des Codes selbst stehen im Eintrag darunter.
|
||||
|
||||
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv (keine Migration), dann unter Formulare das Aussehen des Kontaktformulars wählen.
|
||||
|
||||
## 1.15.0 – Formulare, Statistik, Design und Seitenleiste überarbeitet (15.09.2026)
|
||||
|
||||
**Anlass:** Umut fand die Formularanlage unschön und wollte Felder löschen können, die Statistik „lieblos“, auf der Designseite vordefinierte Farben, eigene Farben für Link- und Seitenfarbe und eine Vorschau der Schriften, und den Fuß der Seitenleiste (Fassung, Benutzer, Abmelden) besser. Nachgeschoben: eine Vorschau des Kontaktformulars, in der Rahmen-, Hintergrund- und Buttonfarbe direkt gewählt werden.
|
||||
|
||||
**Formulare (`forms.html.twig`, neu `form-editor.js`).** Jedes Feld ist eine Karte mit Nummer, Beschriftung, Typ, technischem Name, Hilfetext, Pflicht-Schalter und einem Entfernen-Knopf; „Feld hinzufügen“ klont eine `<template>`-Karte. Der technische Name folgt der Beschriftung (`Größe der Fläche` → `groesse_der_flaeche`), bis jemand ihn selbst schreibt; Optionen erscheinen nur bei einer Auswahl; eine Einwilligung ist sichtbar immer Pflicht. Ohne Skript bleibt alles bedienbar: eine Karte ohne Beschriftung wird nicht gespeichert. Neu ist der Abschnitt **Aussehen**: Buttonbeschriftung, Rahmen und Hintergrund der Felder, Button- und Buttonschriftfarbe, jeweils aus der Palette oder als eigener Wert (`FormRepository::COLORS`, geprüft mit `StyleSchema::COLOR`). Daneben eine **Vorschau**, die jeder Eingabe folgt und den Absendeknopf mit dem Standardbutton der Website zeichnet (`buttonSample`). Auf der Website schreibt `FormRepository::css()` je Formular eine Regel `.block-form[data-form="…"]{…}` in den gehashten Stylesblock (`PageController::renderPage`); Felder lesen `--form-border`/`--form-bg` (`site.css`), der Button bekommt `--kb-fill`/`--kb-ink` **und** die Darstellungsvariablen neu – am Wurzelelement wären sie schon mit den alten Werten aufgelöst. Das „Neues Formular“ ist ein zugeklapptes Karten-Details am Ende.
|
||||
|
||||
**Statistik (`statistics.html.twig`, `Statistics::overview()`).** Sechs Kennzahlkacheln: Aufrufe mit Vergleich zum gleich langen Zeitraum davor (`previousTotal`), Schnitt je Tag, stärkster Tag mit Datum und Wochentag (`busiestDay`), aufgerufene Seiten (`pageCount`), Aufrufe über Verweise (`referred`) und nicht gefundene Adressen (`missingTotal`). Der Verlauf hat Rasterlinien, jeder Balken einen `<title>` mit Tag und Zahl, der stärkste Tag ist hervorgehoben; dazu ein neues Wochentagsdiagramm im Schnitt (`weekdays`). Die Ranglisten tragen Rang und einen Anteilsbalken (SVG-Breite, kein Inline-Stil). Der Zeitraum ist eine Leiste mit gedrücktem Knopf in der Kopfzeile; „Was diese Zahlen nicht sagen“ ist zugeklappt.
|
||||
|
||||
**Design (`theme.html.twig`, neu `theme-panel.js`).** Die Palette sind Farbkarten, deren Fläche der Farbwähler selbst ist, mit Hexwert, Name, Schlüssel und Entfernen. Sieben fertige Paletten (`Theme::PALETTES`: Wald, Ozean, Sand, Koralle, Schiefer, Olive, Nacht; die Linkfarbe erreicht auf dem Papierton mindestens 4,5:1) ersetzen die fünf Grundschlüssel und lassen eigene Farben stehen. Schriften sind Karten mit einer Probe in der Schrift selbst (lokale `fonts.css` wird geladen, wenn vorhanden – `FontLibrary::hasStylesheet()`), Schnitte als Chips, die Auswahlen für Fließtext/Überschriften/Button folgen neuen Schriften. Text-, Seiten- und Linkfarbe nutzen das Palettenbedienelement des Editors (`createColorControl`), das bei jeder Palettenänderung neu aufgebaut wird; daneben eine Typografie-Vorschau mit Überschriftenskala, Zeilenhöhe, Farben und Unterstreichung.
|
||||
|
||||
**Seitenleiste (`shell.html.twig`).** Unten eine Kontokarte: Avatar, Name, Rolle (Administrator/Veröffentlicher/Redakteur), rechts ein Abmeldesymbol mit Beschriftung für Vorlesesoftware; darunter „KantSites 1.14.2“ mit dem Update-Hinweis in einer Zeile. Neue Symbole `logout`, `plus`, `remove`. Auf Tablets wischt nur noch die Liste, Konto und Abmelden bleiben rechts im Bild; dafür braucht die Rasterspalte `minmax(0, 1fr)`, sonst schob die Leiste die Seite über den Rand.
|
||||
|
||||
Die Formularvorschau nennt den Kontrast der Buttonschrift auf der Buttonfarbe (WCAG-Formel, grün ab 4,5:1, gelb ab 3:1, sonst rot), damit eine unlesbare Wahl auffällt, bevor sie auf der Website steht. Die `pattern`-Attribute der Schlüsselfelder maskieren den Bindestrich (`[a-z][a-z0-9\-]*`): Chrome prüft `pattern` inzwischen mit dem `v`-Flag und lehnte das alte Muster mit einem Konsolenfehler ab – die Prüfung war damit wirkungslos.
|
||||
|
||||
**Geprüft:** `phpunit` mit PHP 8.4 508 von 508 grün, mit PHP 8.2 508 grün mit einer vorbestehenden Warnung in `UpdaterTest` (Git-Umgebung); neu getestet: Formularfarben auf der Seite samt CSP-Hash, Ablehnung ungültiger Farben, Statistiksummen und Vorzeitraum, drei Diagramme mit Titeln. `npm run test:editor` vollständig bestanden – darin die axe-Prüfung von Formularen, Statistik und Design. `test:buttons` besteht den Designseiten-Teil (Vorlage wählen, speichern, Werte erhalten) und scheitert danach wie `test:navigation-ux` an einem Klick auf den Strukturbaum des Editors (`#structure [data-block-id=…]` „not visible“) – eine Stelle außerhalb dieser Änderung, nicht weiter untersucht. `test:admin` scheitert vor den Formularen an zwei vorbestehenden Befunden: dem Wiedergabeknopf des öffentlichen Sliders bei reduzierter Bewegung und `nested-interactive` in den Menü-Zusammenfassungen (Ziehgriff im `summary`). In der Pilotkopie im Browser: Feld hinzufügen/entfernen, Namensableitung, Farbwahl mit Vorschau und Kontrasthinweis, Speichern, Regel und Buttonfarbe auf der öffentlichen Startseite; Palette „Ozean“ übernehmen, Schrift hinzufügen, Linkfarbe wählen; Seitenleiste und Bildschirme auf 1440, 900 und 375 px. Codestil mit php-cs-fixer (verbleibende Abweichung in `tests/CmsTest.php` ist vorbestehend). Veröffentlicht als 1.15.0, siehe Eintrag oben.
|
||||
|
||||
## 1.14.2 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.14.2` (Commit `0aa1ff6`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.14.2 – Fußzeile des Einwilligungshinweises: Einzelwahl links, Rechtslinks rechts.
|
||||
|
||||
**Gebaut aus einem sauberen Checkout des Tags** (`git worktree add var/release-source-1.14.2 v1.14.2`), nicht aus der Arbeitskopie: dort lagen zu diesem Zeitpunkt nicht committete Änderungen einer anderen Sitzung (`site.css`, `archive.html.twig`, `page.html.twig`, `tests/CmsTest.php`, neu `src/Twig/HeaderExtension.php`), und ein erster Build aus der Arbeitskopie hatte sie mitgenommen – der wurde verworfen. Das Archiv enthält keine vorgefertigten `assets/build/`-Bündel mehr (die Arbeitskopie hatte welche, sie entstehen beim ersten Aufruf); daher 3546 statt 3552 Dateien.
|
||||
|
||||
**Anhänge:** `kantsites-1.14.2.zip` (4,7 MB, SHA-256 `236dff086267591a51198b55ddfbaf2ee999db8a081ec5bb1d28752260a3db3f`), `.sha256`, `install.php`; hochgeladen mit `var/publish-release-1.14.2.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** PHP 506 Tests grün (ein erster Lauf zeigte einen Fehler, während die andere Sitzung `tests/CmsTest.php` bearbeitete; zwei weitere Läufe grün); HTTP-Archiv-Update 1.14.1 → 1.14.2 `ok`; nach der Veröffentlichung ohne Anmeldung alle drei Anhänge bytegleich, `latest` = `v1.14.2`, Installeradresse 200.
|
||||
|
||||
## 1.14.2 – Einwilligungshinweis: Fußzeile aufgeräumt (15.09.2026)
|
||||
|
||||
**Anlass:** Umut fand „Einzeln wählen“ und die Rechtslinks unordentlich und schlug links/rechts vor.
|
||||
|
||||
**Umgesetzt (`consent-banner.html.twig`, `site.css`):** Beide stehen in einer Zeile `.consent-foot` – Einzelwahl links, Datenschutz · Impressum rechts, auf gemeinsamer Grundlinie. Aufgeklappt nimmt die Einzelwahl die volle Breite (`[open]{flex-basis:100%}`), die Links rutschen rechtsbündig darunter. Die Links stehen ohne Punkt dazwischen mit 14 px Abstand; ohne beide Adressen entfällt die Zeile. Ein Stolperstein: `.consent-bar p` schlug `.consent-links` und nahm den rechten Rand weg – der Selektor heißt jetzt `.consent-bar p.consent-links`.
|
||||
|
||||
**Geprüft:** vier Gestaltungsvarianten auf 1440 und 390 px, zu- und aufgeklappt (Screenshots `reports/consent-look/*-offen.png`), sechs Einwilligungstests grün. Veröffentlicht als 1.14.2, siehe Eintrag oben.
|
||||
|
||||
## 1.14.1 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.14.1` (Commit `0bdcc7f`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.14.1. Enthält den gestaltbaren Einwilligungshinweis, den bei offener Frage erreichbaren Nach-oben-Knopf, den korrigierten Abstand unten und die orangen Beschriftungen im Editor; dazu `860bed9` (Seed-Skript Tagespflege Moldovan, nicht im Archiv).
|
||||
|
||||
**Anhänge:** `kantsites-1.14.1.zip` (4,7 MB, 3552 Dateien, SHA-256 `c0620a3155db894373199dbe2e286744dd7b21eed2c355e8d2a890652d1d203e`), `.sha256`, `install.php` mit eingetragener Prüfsumme; hochgeladen mit `var/publish-release-1.14.1.py`, erst Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** PHP 505 Tests grün; `browser-check.mjs`, `editor-check.mjs` bestanden; HTTP-Archiv-Update 1.14.0 → 1.14.1 `ok` (Update 302, System 200, Abschluss 302, öffentliche Seite 200). Nach der Veröffentlichung ohne Anmeldung: alle drei Anhänge bytegleich, `/releases/latest` nennt `v1.14.1`, Installeradresse antwortet 200.
|
||||
|
||||
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv, Migrationen abschließen (keine neuen), dann unter Einstellungen → Einwilligung das Aussehen wählen; `var/kantit.db` mit dem korrigierten Kopfmenü hochladen.
|
||||
|
||||
## 1.14.1 – Einwilligungshinweis gestaltbar, Nach-oben-Knopf bei offener Frage, orange Beschriftungen (15.09.2026)
|
||||
|
||||
**Anlass:** Umut wollte das Banner gestaltbar (rund, Farben) und schöner, den Abstand unten korrigiert, belegte Eigenschaften im Editor orange statt nur fett mit Punkt, und Gerätechips, die sich „aktiv überlagern“.
|
||||
|
||||
**Einwilligungshinweis (ADR 097).** Sieben neue Einstellungen: `consentLayout` (karte/leiste/ecke), `consentShape` (weich/rund/eckig), `consentTitle`, `consentColor`, `consentInk`, `consentAccent`, `consentAccentInk`; Farben mit `StyleSchema::COLOR` geprüft, Palette über `Theme::cssColor()` (neu, ersetzt die Kopie im Nach-oben-Knopf). `Consent::styles()` schreibt `:root{--kant-consent-…}` in den gehashten Stylesblock; die Anordnung ist eine Klasse. Neues Grundlayout: Streifen und Symbol in der Knopffarbe, gleich breite Knöpfe, aufklappbares „Einzeln wählen“ mit Pfeil, unterstrichene Rechtslinks; Leiste auf breiten Bildschirmen zweispaltig. Archiv und `/einwilligung` bekommen Theme- und Hinweisvariablen als gehashten Stylesblock – das Archiv trug bisher gar keine Palette.
|
||||
|
||||
**Abstand unten:** 320 px bis 620 px (Leiste bis 299 px hoch), 270 px bis 899 px, 200 px darüber; die Fläche scrollt ab `100dvh − 24px` in sich. Nach-oben-Knopf hebt sich bei Leiste und Ecke auf jeder Breite (`:has()`), bei der Karte bis 799 px.
|
||||
|
||||
**Editor:** belegte Beschriftungen in `--used` (#9d4e0d, 5,6:1); Chips mit 6 px Lücke, Fokusring ohne Versatz, Gruppe `flex:none`. Ein Überlagern der Chips ließ sich in 1.14.0 nicht reproduzieren – am Handy (390 px) liegen die drei Chips bei 22 px Breite mit 6 px Abstand, kein `outline` gezeichnet; die Rückmeldung bezog sich wohl auf den `outline`-Ring von 1.13.0.
|
||||
|
||||
**Geprüft:** PHP 505 Tests grün, darunter neu `testTheConsentQuestionTakesLayoutShapeAndColoursFromTheSettings` (Standard, eigene Werte, unbekannter Palettenschlüssel fällt weg, Einwilligungsseite mit Hash, ungültige Farbe abgewiesen). Chromium: vier Varianten (Karte, Leiste, Ecke rund dunkel, Karte eckig dunkel) auf 1440 und 390 px – Klasse, Radien, Farben und Überschrift stimmen, Nach-oben-Knopf sichtbar, treffbar, ohne Überlappung; `/einwilligung` 200 mit gehashtem `style-src`. Nach-oben-Prüfung auf acht Breiten bestanden. `browser-check.mjs`, `editor-check.mjs` (Einstellungen, Einwilligung) und axe über die Editorseitenleiste bestanden. Screenshots: `reports/consent-look/`, `reports/admin-hints/`.
|
||||
|
||||
**Nicht geprüft:** echtes iOS-Safari; der `@supports not`-Zweig; Lighthouse.
|
||||
|
||||
## Nach-oben-Knopf bei offener Einwilligungsfrage (15.09.2026)
|
||||
|
||||
**Anlass:** Umut meldete, der Nach-oben-Knopf funktioniere auf dem Handy nicht.
|
||||
|
||||
**Befund.** Der Knopf selbst funktioniert auf dem Telefon: In iPhone- und Pixel-Emulation (Chromium), lokal wie auf `web.kant-it.de` (dort bereits 1.14.0 mit `scrolled.js`), erscheint er nach 500 px, ist treffbar, und Tippen springt nach oben – **sobald die Einwilligungsfrage beantwortet ist**. Vorher galt `body.consent-open .scroll-top{display:none}` auf allen Geräten. Live trägt jeder Erstbesuch `consent-open`, und auf dem Telefon bleibt die Leiste unten oft einfach stehen – der Knopf kam dort nie. Kein Fehler im Skript und nicht der alte Safari-Fehler aus 1.13.0.
|
||||
|
||||
**Korrektur (`site.css`, `page.html.twig`).** Der Knopf bleibt bei offener Frage sichtbar. Gemessen deckt die Leiste die rechte Ecke nur bis knapp 800 px ab (bis 620 px volle Breite, 262–285 px hoch; darüber zentriert mit höchstens 620 px). In dieser Stufe setzt sich der Knopf per Ankerpositionierung 12 px auf die Oberkante der Leiste (`anchor-name:--kant-consent`, `bottom:calc(anchor(top) + 12px)`), unabhängig davon, wie hoch ihr Text umbricht. Browser ohne Ankerpositionierung blenden ihn dort weiter aus (`@supports not`). Ab 800 px bleibt er an seinem Platz. Weil ein Anker im Dokument vor dem positionierten Element stehen muss, gibt die Vorlage die Leiste jetzt vor dem Knopf aus.
|
||||
|
||||
**Geprüft:** Chromium, isolierte Kopie der Pilotdatenbank, Einwilligung offen auf 360, 390, 700, 768, 799, 800 und 1440 px sowie beantwortet auf 390 px – in allen acht Fällen sichtbar, keine Überlappung mit der Leiste, treffbar, nach dem Tippen `scrollY` 0. Twig-Lint gültig. PHP-Tests 504 grün, `browser-check.mjs` und `editor-check.mjs` (einschließlich Einwilligungsabläufe) bestanden.
|
||||
|
||||
**Nicht geprüft:** echtes iOS-Safari – WebKit ist für Playwright auf diesem Rechner nicht installiert. Der `@supports not`-Zweig wurde nicht ausgeführt, weil Chromium Ankerpositionierung kann.
|
||||
|
||||
**Nebenbefund, nicht geändert:** Bis 620 px reserviert `body.consent-open` 260 px Abstand unten, die Leiste ist mit Standardtext bis 285 px hoch – auf kleinen Telefonen liegen die letzten rund 25 px der Seite unter der offenen Leiste.
|
||||
|
||||
**Nächster Schritt:** nicht committet und nicht veröffentlicht; live erst mit einem Release (z. B. 1.14.1).
|
||||
|
||||
## 1.14.0 veröffentlicht (15.09.2026)
|
||||
|
||||
Tag `v1.14.0` (Commit `a434e0c`), Release: https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.14.0. Enthält die Schriften vom eigenen Server, den Hinweisstreifen, System und Update auf dem Telefon, die Gerätechips sowie Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten.
|
||||
|
||||
**Anhänge:** `kantsites-1.14.0.zip` (4,7 MB, SHA-256 `8360566b1eaed670b29f506262953d3b17f95893e23c7f4a940ff6d5e3f62735`), `.sha256`, `install.php` mit eingetragener Prüfsumme. Hochgeladen mit `var/publish-release-1.14.0.py` über den Git-Zugang dieses Rechners, erst als Entwurf, dann veröffentlicht.
|
||||
|
||||
**Geprüft:** 504 PHP-Tests grün; `browser-check.mjs` und `editor-check.mjs` bestanden; HTTP-Archiv-Update 1.12.1 → 1.14.0 `ok`. Nach der Veröffentlichung ohne Anmeldung: alle drei Anhänge bytegleich mit den lokalen Dateien, `/releases/latest` nennt `v1.14.0`, die Installeradresse `…/releases/download/v1.14.0/kantsites-1.14.0.zip` antwortet mit 200.
|
||||
|
||||
**Nächster Schritt:** Update auf `web.kant-it.de` über System → Update aus dem Archiv, danach Migrationen abschließen und `var/kantit.db` mit dem korrigierten Kopfmenü hochladen.
|
||||
|
||||
## Globale Hinweise, der Schriftenknopf und die Gerätechips (15.09.2026)
|
||||
|
||||
**Anlass:** Umut fehlte ein Hinweis, der auch außerhalb der Systemseite sagt, dass etwas zu tun ist; der Knopf „Schriften holen“ sah nicht aus wie die übrigen; und im Designbereich störte ein roter Rahmen an den Gerätechips, der abgeschnitten wirkte.
|
||||
|
||||
**Hinweisstreifen auf jedem Verwaltungsbildschirm (`kant_admin_notices()`, `admin/shell.html.twig`).** Über dem Inhalt steht jetzt, was einen Knopf braucht, und der Knopf gleich daneben: „Fassung X ist verfügbar“ → *Jetzt aktualisieren* (System), „Schriftdateien fehlen: …“ → *Schriften holen* (Design). Keine Zeile davon geht ins Netz – die verfügbare Fassung steht im Protokoll der täglichen Aufgabe `update-check`, die fehlenden Schriften sind ein Blick in den Ordner. Der Updatehinweis erscheint nur für `ROLE_ADMIN`, weil nur die auf die Systemseite dürfen. Warm statt rot: es ist eine Aufgabe, kein Fehler. Auf 390 px bricht der Streifen um, der Knopf rutscht unter den Text.
|
||||
|
||||
**Schriftenknopf.** Er trug gar keine Klasse und war deshalb der einzige Knopf der Verwaltung im Standardaussehen des Browsers. Jetzt `primary` (bzw. `secondary` beim erneuten Holen), und der ganze Abschnitt steht wie alles andere in einer Karte mit Kopf und Körper.
|
||||
|
||||
**Gerätechips D/T/M (`editor.css`).** Der rote Kasten war `outline:2px` mit `outline-offset:1px` auf dem gerade bearbeiteten Gerät. Ein `outline` liegt außerhalb des Kästchens: bei 2 px Abstand lief er unter den Nachbarchip und wurde von der seitlich beschnittenen Sidebar gekappt – daher der abgeschnittene Eindruck. Jetzt zwei Zustände mit zwei Mitteln:
|
||||
|
||||
- **belegt** (dieses Gerät trägt einen eigenen Wert): gefülltes Orange `--used: #9d4e0d`, weiße Schrift – 5,6:1 Kontrast. Dieselbe Farbe trägt der Punkt vor der Beschriftung, damit „hier steht ein eigener Wert“ überall gleich aussieht.
|
||||
- **gerade bearbeitet**: 2 px Rahmen **innen** (`border-width`, kein `outline`), auf belegten Chips zusätzlich ein weißer Innenring. Nichts ragt mehr aus dem Kästchen, nichts wird beschnitten.
|
||||
|
||||
Chips von 19 auf 22 px, Abstand von 2 auf 4 px – auf einen Blick lesbar, welche Geräte an einer Eigenschaft hängen.
|
||||
|
||||
**Geprüft** (isolierte Kopie der Pilotdatenbank, künstlich auf „Lato 300 fehlt“ und „Fassung 9.9.9 verfügbar“ gesetzt): beide Streifen erscheinen auf `/admin`, tragen die richtigen Ziele, und auf 390 px bleibt die Dokumentbreite bei 390. Schriftenknopf trägt `primary`. Chips: belegte auf `rgb(157,78,13)`, bearbeitete mit 2 px Rahmen, kein gezeichnetes `outline` mehr. axe über die Editorseitenleiste ohne Verstöße. PHP-Tests: 498, ein bekannter Fehler (`testAnyPublishedPageCanBecomeTheRootAndEverythingFollows`). Screenshots: `reports/admin-hints/`.
|
||||
|
||||
**Zu beachten:** `admin.css` und `editor.css` hängen an `?v={{ kant_version() }}`. Bis die `VERSION` steigt, liefert der Browser die alten Fassungen aus dem Zwischenspeicher.
|
||||
|
||||
## Schriften nur noch vom eigenen Server, kein sichtbarer Schriftwechsel mehr (14.09.2026)
|
||||
|
||||
**Anlass:** Umut sah im Browser, wie die Schrift kurz von fett auf dünn sprang, und stellte zwei Regeln auf: keine Schriften über Adressen von Google, und kein Nachladen nach dem fertigen Seitenaufbau.
|
||||
|
||||
**Zwei getrennte Ursachen, beide belegt.**
|
||||
|
||||
1. **Der späte Schnitt.** `FontLibrary::preload()` holte je Familie nur die **ersten zwei** Schnitte vorab. Das Kant-IT-Theme nennt Lato 400, 700 und 900; die Überschriften stehen auf 800/900 und landen damit auf Lato 900 – dem einzigen Schnitt ohne Vorabholen. Gemessen am Nachbau: `lato-400` und `lato-700` starteten bei 151 ms, `lato-900` erst bei 156 ms, also nach dem Stylesheet. Mit `font-display:swap` zeichnet der Browser die Überschrift bis dahin in der Ersatzschrift und tauscht sie danach – der gemeldete Sprung. Jetzt wird jeder Schnitt des Themes vorab geholt (lateinischer Grundbereich; die erweiterten Bereiche bleiben draußen).
|
||||
2. **Google in der Editorvorschau.** `EditorController` und `PartialController` gaben der Vorlage `googleFonts` und **kein** `localFonts`. In der Vorschau kamen die Schriften damit bei jedem Aufruf von `fonts.googleapis.com` und `fonts.gstatic.com` – zwei fremde Verbindungen hintereinander, und genau dort arbeitet Umut am meisten. Beide Vorschauen nutzen jetzt dieselben lokalen Dateien wie die öffentliche Seite.
|
||||
|
||||
**Die Regel steht jetzt im Code, nicht in der Einstellung.** `page.html.twig` hat keinen Google-Zweig mehr; fehlen die Schriftdateien, zeigt die Website die Ersatzschrift des Themes. Die Kopfzeilen lassen keinen fremden Schrifthost mehr zu – öffentlich `font-src 'self'`, in der Verwaltung `style-src 'self'; font-src 'self'` statt der bisherigen Ausnahmen für `fonts.googleapis.com`/`fonts.gstatic.com`. `Theme::googleFontsUrl()` ist entfernt; das Herunterladen der Dateien über die Theme-Seite bleibt unverändert und baut seine Adresse selbst. Die Vorabholen-Verweise stehen jetzt **vor** dem Stylesheet, und die Texte auf der Theme-Seite sagen nicht mehr „kommen zurzeit von Google“, sondern „die Dateien fehlen, bis dahin die Ersatzschrift“.
|
||||
|
||||
**Geprüft** (Chromium, kalter Zwischenspeicher, isolierte Kopie der Pilotdatenbank): alle drei Schriftdateien starten bei 96 ms und sind bei 166–168 ms fertig, der erste sichtbare Inhalt erscheint bei 292 ms – die Schriften stehen also vor dem ersten Zeichnen, es gibt nichts mehr zu tauschen. Jede Datei wird genau einmal angefragt. Über die ganze Sitzung – öffentliche Seite, Anmeldung, Seiteneditor mit Vorschau, Bereiche – **keine einzige Anfrage außerhalb des eigenen Servers**. PHP-Tests: 498, ein bekannter Fehler (siehe unten). `npm run test:browser` und der Lighthouse-Lauf sind **nicht** gelaufen.
|
||||
|
||||
**Tests angepasst:** `testFontsAreReadFromGoogleStylesheetAndServedFromHere` prüft jetzt die Ersatzschrift statt des Google-Verweises und alle drei Vorabhol-Dateien; `testThemeDrivesEveryPageAndGoogleFontsOpenTheirOwnPolicy` heißt `…AndNoFontIsEverFetchedFromGoogle` und prüft die engere Kopfzeile. In `testEveryAllowedIconHasADrawingAndTakesItsColourFromTheBlock` stand eine echte Zeilenschaltung im Suchmuster; unter Windows mit `core.autocrlf=true` schlägt das fehl, sobald die Arbeitskopie CRLF trägt. Das Muster nimmt jetzt `\R` und gilt für beide Schreibweisen; die Vorlage selbst bleibt unverändert.
|
||||
|
||||
**Weiterhin offen, nicht von dieser Aufgabe:** `CmsTest::testAnyPublishedPageCanBecomeTheRootAndEverythingFollows` schlägt fehl – der Test erwartet `<a href="/" aria-current="page">Start</a>`, das Menü schreibt seit den Symbolen `<span class="nav-label">` um die Beschriftung.
|
||||
|
||||
## Kopfmenü im Hochformat und die Update-Ansicht auf dem Handy (14.09.2026)
|
||||
|
||||
**Anlass:** Umut meldete, das Mobilmenü im Kopf erscheine nur im Querformat, und bat darum, Texte und Ansichten der Update-Seite fürs Handy zu kürzen.
|
||||
|
||||
**Befund zum Kopfmenü – falsch gebaut, kein Fehler im CMS.** Der Kopfbereich trug zwei Menüblöcke: `area-header-menu` (waagerecht, `mobile: display:none`) und daneben `area-header-burger` (eigener Burger-Block, nur mobil sichtbar). Der veröffentlichte Stand auf `web.kant-it.de` kennt den zweiten Block nicht – abgerufen am 14.09.2026 enthält das Markup dort nur `area-header-menu` samt `@media (max-width:767px){#area-header-menu{display:none}}`. Damit ist im Hochformat (bis 767 px) gar kein Menü da, während im Querformat (768–1024 px) das Standardverhalten `collapse: tablet` denselben Block zum Burger zusammenlegt und ein Knopf erscheint. Genau die gemeldete Beobachtung. In `var/kantit.db` lag zwar ein zweiter Burger-Block, der das Loch stopfte, aber mit eigener Gestaltung: im Hochformat ein 161 px breiter Knopf, im Querformat der 101 px breite des waagerechten Menüs.
|
||||
|
||||
**Behoben in `var/kantit.db`:** ein einziger Menüblock `area-header-menu` mit `collapse: tablet` und `burgerLabel: both`, ohne `display:none` auf Mobil, `burgerFontSize: 14px`; der doppelte Block `area-header-burger` ist entfernt (Kopfbereich Fassung 8, Sicherung `var/kantit.db.vor-kopfmenue-20260914-2244.bak`). Geprüft am laufenden Nachbau: 375 px Hochformat und 844 px Querformat zeigen denselben Knopf („MENÜ“, 101 × 45 px, 14 px), die Liste bleibt in beiden verborgen und die aufklappende Fläche liegt über die volle Breite unter dem Kopf; ab 1025 px stehen die vier Einträge wieder waagerecht. Startkonfiguration `kantit` (Port 8130) in `.claude/launch.json` ergänzt.
|
||||
|
||||
**Update-Ansicht (`templates/admin/system.html.twig`, `public/assets/admin.css`).** Die Seite schob sich auf dem Handy seitlich aus dem Bild: `.stack` ist ein Raster mit automatischer Spalte, und eine automatische Rasterspalte wächst auf die Mindestbreite ihres breitesten Inhalts – eine lange Datenbankpfadangabe reichte. `.stack` bekommt `minmax(0, 1fr)`, `.facts` beide Spalten als `minmax(0, …)` und unter 620 px nur noch eine, `.card-body` und die Prüfpunkte `min-width: 0` samt `overflow-wrap: anywhere`. Die Sicherungstabelle wird unter 620 px zu gestapelten Blöcken (`.table-stack`, Spaltenüberschrift als `data-label`), statt waagerecht zu scrollen. Texte gekürzt und das Kleingedruckte in zugeklappte `<details class="hint">` gelegt, ebenso die älteren 15 der 20 Protokollzeilen.
|
||||
|
||||
**Geprüft:** 390 × 844 mit Touch-Emulation gegen eine isolierte Kopie der Pilotdatenbank (`tests/pilot-fixture.php`, eigenes Zufallskonto): Dokumentbreite 390 statt 492, kein Element mehr breiter als der Inhaltsbereich, Wortzahl der Seite 1017 → 556, axe ohne Verstöße. `/admin`, `/admin/media`, `/admin/menus`, `/admin/forms`, `/admin/theme`, `/admin/settings` bleiben mobil bei 390 px; dieselben Seiten und `/admin/system` bei 1440 px unverändert breit. Screenshots: `reports/system-mobile/`.
|
||||
|
||||
**Bekannter Fehler, nicht von dieser Aufgabe:** `CmsTest::testAnyPublishedPageCanBecomeTheRootAndEverythingFollows` schlägt fehl (498 Tests, 1 Fehler) – der Test erwartet `<a href="/" aria-current="page">Start</a>`, das Menü schreibt seit den Symbolen `<span class="nav-label">` um die Beschriftung. Auch ohne diese Änderungen und mit beiden PHP-Fassungen reproduzierbar.
|
||||
|
||||
**Nächster Schritt:** Umut spielt `var/kantit.db` auf den Server; erst dort ist das Kopfmenü im Hochformat live. Für die Admin-Änderungen die `VERSION` beim nächsten Release erhöhen, sonst liefert der Browser `admin.css?v=1.13.0` weiter aus dem Zwischenspeicher.
|
||||
|
||||
## 1.14.0 – Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten; Elektro Bernhard aktualisiert (14.09.2026)
|
||||
|
||||
**Anlass:** Umut nannte die tatsächlichen Bürozeiten (Mo–Fr 7:30–17:00), das Einsatzgebiet von Karlsruhe bis Offenburg und wollte die FAQ „mit allem, was notwendig ist“ – das JSON-LD, das das CMS bisher nicht hergab (siehe Lücke in den Einträgen vom Abgleich).
|
||||
|
||||
**Umgesetzt (ADR 095):** Einstellungen `openingHours` und `areaServed` mit Formular unter Unternehmensdaten; `SiteSettings::organisation()` gibt `openingHoursSpecification` (gleiche Zeiten zusammengefasst) und `areaServed` aus. Akkordeon-Eigenschaft `faq` (Schema v23, Kästchen im Editor); `PageController` baut daraus eine `FAQPage` mit Frage und Antwort aus dem sichtbaren Inhalt. Fünf neue Tests: Zusammenfassung und Reihenfolge der Zeiten, Abweisung unvollständiger oder verdrehter Zeiten, FAQPage nur aus gekennzeichneten Akkordeons und nur mit Antwort, Abweisung der Kennzeichnung vor v23, und – nach einem Fund der vollen Suite – dass ein Bildschirm, der die geladenen Einstellungen unverändert zurückgibt (Anlegen der Rechtsseiten, Installer), Öffnungszeiten und Einsatzgebiet nicht löscht.
|
||||
|
||||
**Elektro Bernhard:** Bürozeiten überall Mo–Fr 07:30–17:00 Uhr (Fuß, Kontakt, Beschreibung, FAQ); Einsatzgebiet Baden-Baden, Sinzheim, Hügelsheim, Iffezheim, Bühl, Rastatt, Kuppenheim, Muggensturm, Gaggenau, Gernsbach, Murgtal, Ettlingen, Karlsruhe, Achern, Oberkirch, Offenburg in Text und `areaServed`; FAQ um „Wann ist das Büro erreichbar?“ und „Wie läuft eine Anfrage ab?“ ergänzt und als FAQ gekennzeichnet. Die Öffnungszeiten weichen bewusst von der laufenden Website ab (dort Mo–Do 7:00–16:30, Fr bis 13:00) – Umuts Angabe gilt.
|
||||
|
||||
**Arbeitsweise:** Weil eine parallele Sitzung im Hauptarbeitsbaum dreizehn Dateien offen hatte, darunter Validator, PageController und Editor, entstand diese Änderung im eigenen Worktree `var/wt-faq` auf dem Zweig `oeffnungszeiten-faq` und wurde von dort auf `main` gebracht. Der Hauptarbeitsbaum muss danach `git pull` ausführen.
|
||||
|
||||
## 1.13.0 – Drei Symbole mehr, gestaltbarer Menü-Knopf, Nachbau „Tagespflege Moldovan“ (14.09.2026)
|
||||
|
||||
**Anlass:** Umut ließ die ZIP-Vorlage „Tagespflege Moldovan“ nachbauen. Beim Nachbau fehlten dem Symbolsatz genau die drei Bilder, die die Vorlage zeigt; danach kamen zwei Meldungen vom Telefon: das Menü sei nicht zu sehen, und „Scroll to top“ funktioniere nicht.
|
||||
|
||||
**Nachbau (`scripts/seed-tagespflege-moldovan.php`, `scripts/reset-tagespflege-moldovan.sh`).** Der Onepager aus dem Paket vom 09.09.2026, gebaut aus gewöhnlichen Bausteinen: Kopfleiste mit Telefonzeile, klebender weißer Kopf mit Wortmarke, Hero mit der limettenen Schnittfläche hinter dem Foto (`clip-path` als eigenes CSS auf einer Fläche), Notizkarte und Bildunterschrift, Werteband, Willkommen mit Porträt-Signatur, drei Angebotskarten, Zeitleiste mit Verbindungslinie aus Flächen, dunkler Kontaktbereich mit schräger Fläche, Formularkarte und Fuß mit limettenem Abschluss. Farben, Schriftgrößen, Abstände und Umbrüche sind aus `style.css` übernommen, ebenso die drei Gerätestufen. Eigene Installation: `var/tagespflege-moldovan.db`, `var/tagespflege-moldovan-media`, Bildquellen in `var/tagespflege-moldovan-assets`, Startkonfiguration auf Port 8089. Zwei bewusste Abweichungen: das Formular verschickt serverseitig statt eine Mailto-Nachricht vorzubereiten, und das Markenzeichen liegt als PNG vor, weil die Medienverwaltung JPEG, PNG und WebP nimmt. Impressum und Datenschutz fehlen wie in der Vorlage – für einen Livegang nachzutragen.
|
||||
|
||||
**Symbole (ADR 094).** `people`, `music` und `home` ergänzen den Satz in `DocumentValidator::ICONS`, `blocks/icon.html.twig`, `editor.js` und `menu-editor.js`. Ein Test hält Liste und Zeichnungen zusammen. `.block-icon .block-icon{color:inherit}` lässt die am Block eingestellte Farbe endlich an der Zeichnung ankommen – das eingebettete SVG trug dieselbe Klasse wie seine Hülle und setzte die Akzentfarbe erneut.
|
||||
|
||||
**Menü-Knopf (ADR 094).** Neue Eigenschaft `burgerLabel` (`both`, `text`, `icon`; „Nur Symbol“ behält den Namen als `visually-hidden`) und die neue Stilgruppe `menu` mit dem Ziel `burger`: `burgerColor`, `burgerBackgroundColor`, `burgerBorderColor`, `burgerBorderWidth`, `burgerBorderRadius`, `burgerFontSize`. Der Compiler schreibt `#id .menu-burger>summary{…}`. Im Kopfbereich klappt das Menü als Fläche über die volle Breite unter den Kopf auf statt inline in der schmalen Spalte neben der Marke – das war die gemeldete „unsichtbare“ Navigation.
|
||||
|
||||
**Nach-oben-Knopf (ADR 094).** Die Einblendung hängt nicht mehr an `animation-timeline: scroll()`, sondern an den Klassen `kant-top-armed` und `kant-top-shown`, die `scrolled.js` setzt; das Skript wird jetzt auch geladen, wenn nur dieser Knopf es braucht. Ohne Skript bleibt der Knopf sichtbar.
|
||||
|
||||
**Geprüft:** PHP-Suite 498 Tests (fünf neue: Symbolliste gegen Zeichnungen, drei Knopfvarianten, abgewiesene Variante, Knopffarben am `summary`); einziger Fehlschlag ist der bekannte Altfehler `testAnyPublishedPageCanBecomeTheRoot…`. 33 JavaScript-Tests, Twig-Lint, PHP-Stilprüfung sauber, Editor-Browserprüfung vollständig erfolgreich einschließlich axe. Playwright auf dem Nachbau bei 1440, 900 und 390 px sowie als iPhone 13: Seite gegen die Vorlage verglichen, Menü öffnet als Fläche unter dem Kopf, Sprungziele, 404-Seite, keine Konsolenfehler. Nach-oben-Knopf auf der Elektro-Bernhard-Installation als iPhone geprüft: oben verborgen, ab der Schwelle sichtbar, springt an den Anfang.
|
||||
|
||||
**Veröffentlicht:** Commit `73719ba` und Tag `v1.13.0` auf `origin`; [Release 1.13.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.13.0) mit ZIP (3546 Dateien, 4,6 MB), SHA-256-Datei und `install.php`. Das Archiv wurde aus einem sauberen Worktree des Tags gebaut (`var/release-source-1.13.0`), `vendor/` ohne Entwicklungspakete; die einzige `.env` im Archiv ist die Vorlage ohne Geheimnis. Die drei Anhänge wurden anonym von der Releaseadresse geladen und bytegenau mit den gebauten Dateien verglichen; SHA-256 `14e333e0…59dcbf`, identisch mit der Prüfsumme, die `install.php` mitbringt.
|
||||
|
||||
## 1.12.1 – Elektro Bernhard gemessen: Lighthouse und axe (14.09.2026)
|
||||
|
||||
**Anlass:** Umut fragte nach Google-Bewertung, SEO, llms.txt und Barrierefreiheit der neuen Website.
|
||||
|
||||
**Ergebnis:** Lighthouse-Mediane auf vier Seiten, Mobil und Desktop: alles 100, nur die mobile Startseite hat Leistung 99 (LCP 2,1 s durch das Hero-Foto). axe auf sechs Seiten in zwei Breiten: null Verstöße. Details und Messbedingungen in `docs/QUALITY.md`.
|
||||
|
||||
**Behoben:** drei Kontrast-/Gliederungsbefunde in der Website (Skript) und einer im CMS: der Pause-Knopf des Sliders verletzte WCAG 2.5.3 (sichtbarer Text „Pause“, Name „Automatische Wiedergabe pausieren“); `slider.js` bildet den Namen jetzt aus dem sichtbaren Text, `scripts/admin-quality-check.mjs` folgt.
|
||||
|
||||
**Veröffentlicht:** Commit `042aef9` und Tag `v1.12.1` auf `origin`; [Release 1.12.1](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.12.1) mit ZIP (3546 Dateien, 4,6 MB), SHA-256-Datei und `install.php`, gebaut aus dem sauberen Worktree `var/release-source-1.12.1`; alle drei Anhänge anonym geladen und bytegenau verglichen, SHA-256 `c7a2de81…8f1a23`.
|
||||
|
||||
## 1.12.0 – Soziale Profile als Baustein; Elektro Bernhard neu gestaltet (14.09.2026)
|
||||
|
||||
**Anlass:** Umut fand den ersten Nachbau „grauenhaft“, fragte nach dem transparenten Menü und vermisste Social-Icons im CMS – „gescheit“, also einmal zentral gepflegt und überall mit eigener Größe und Farbe einsetzbar.
|
||||
|
||||
**Soziale Profile (Dokumentschema v23, ADR 093).** Unter Einstellungen → Unternehmensdaten gibt es je Netzwerk ein Adressfeld (`SocialProfiles::NETWORKS`: Facebook, Instagram, LinkedIn, YouTube, TikTok, X, Threads, Pinterest, Xing, WhatsApp, Google-Unternehmensprofil; nur HTTPS). Der neue Baustein `social` (Werkzeugkasten → Bausteine → „Soziale Profile“) zeigt sie und bestimmt Größe (die 15 Symbolstufen), Form (nur Symbol, runde oder eckige Fläche), Anordnung, Beschriftung, neuen Tab und optional eine Teilmenge der Netzwerke; Farbe, Hover-Farbe, Abstand und Ausrichtung kommen aus den freien Blockstilen. Die Vorlage liest die Profile über das Twig-Global `siteSettings`, ohne Profile gibt sie nichts aus und zeigt im Editor einen Hinweis. Die Adressen stehen automatisch in `sameAs` der strukturierten Daten. Symbole sind eigene Umrisszeichnungen im Strich des Symbolsatzes. Neue Dateien `src/Content/SocialProfiles.php`, `templates/blocks/social-icon.html.twig`; Änderungen an Validator, Einstellungen, Controller, Vorlagen, `site.css`, Editor (Modell, Eigenschaften, Bibliothekssymbol).
|
||||
|
||||
**Zur Frage nach dem transparenten Menü:** Nein, im ersten Nachbau war der Kopf weiß. Jetzt nutzt die Website den Mechanismus aus 1.11.1: `headerOverlay` und `stickyHeader` an, Kopfsektion mit `#ffffff00` als Hintergrund und Weiß mit Schatten `scrolled`. Das Logo (schwarze Schrift, transparenter Grund) sitzt auf einer weißen Fläche, damit es über dem dunklen Foto lesbar bleibt.
|
||||
|
||||
**Elektro Bernhard neu gestaltet** (`scripts/seed-elektro-bernhard.php`, vollständig neu geschrieben): dunkler Hero mit dem Teamfoto als Hintergrund und Überlagerung, Lato aus dem lokalen Schriftordner, Versal-Kleinzeilen in Rot, Leistungskarten mit Symbolfläche, Schatten und Hover-Lift, Foto mit starkem Schatten neben Text und vier Stärken, Kundenstimmen als Slider mit sechs echten Rezensionen (Punkte, Automatik 7 s), dunkles Solarband mit dunkler Karte, Akkordeon in weichen Kästen, Formular als weiße Karte, jede Unterseite mit dunklem Seitenkopf unter dem schwebenden Kopfbereich, Handlungsaufruf als dunkle Karte, Einblendungen mit gestaffelten Verzögerungen auf allen Abschnitten. Soziale Profile in der Kopfleiste (16 px, nur Symbol, auf dem Telefon ausgeblendet) und im Fuß (20 px, runde Fläche, Hover rot). „Nach oben“-Knopf in Rot. Keine Änderung am CMS-Code außer dem Baustein.
|
||||
|
||||
**Geprüft:** PHP-Suite 492 Tests, davon vier neue für die Profile (Darstellung, Teilmenge, Beschriftung, neuer Tab, sameAs, Abweisung falscher Werte, HTTP-Adresse); einziger Fehlschlag ist der bekannte Altfehler `testAnyPublishedPageCanBecomeTheRoot…`. 33 JavaScript-Tests, Twig-Lint, PHP-Stilprüfung (Fixer angewendet). Editor-Browserprüfung vollständig erfolgreich einschließlich axe. In der App-Vorschau: Startseite von oben bis unten, Kopf transparent und nach Scrollen weiß, Leistungsseite, Einstellungen mit den elf Profilfeldern, Fußbereich im Editor mit geöffnetem Eigenschaftenfeld des Bausteins (Größe, Form, Anordnung, Beschriftung, Tab, Netzwerke, Sichtbarkeit); Mobilansicht 375 px mit Hamburger, Kopfleiste einzeilig. Alle Adressen 200, `/gibtsnicht` 404, `sameAs` mit drei Profilen.
|
||||
|
||||
**Veröffentlicht:** Commit `fd4cb75` und Tag `v1.12.0` auf `origin`; [Release 1.12.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.12.0) mit ZIP (3546 Dateien, 4,6 MB), SHA-256-Datei und `install.php`. Das Archiv wurde aus einem sauberen Worktree des Tags gebaut (`var/release-source-1.12.0`), die drei Anhänge anonym von der Releaseadresse geladen und bytegenau mit den gebauten Dateien verglichen; SHA-256 `5cc745f6…d5195e`. Während dieser Arbeit hatte eine parallele Sitzung 1.11.3 und 1.11.4 veröffentlicht und `VERSION` zurückgesetzt; 1.12.0 folgt darauf, weil ein neuer Baustein und ein neues Schema kein Patch sind.
|
||||
|
||||
## Tagespflege Moldovan – getrennte Installation vorbereitet (14.09.2026, Nachbau offen)
|
||||
|
||||
Auftrag: ZIP-Vorlage exakt nachbauen, eigene Datenbank und Medien; ausdrücklich keine CMS-Codeänderung, Inhalte und Gestaltung ausschließlich über die vorhandene Editoroberfläche bearbeitbar aufbauen.
|
||||
|
||||
Vorlage gefunden unter `C:/Users/UmutKantarci/AppData/Local/Temp/codex-file-preview-oZxj9Z/Tagespflege_Moldovan_Website.zip`, sicher nach `var/tagespflege-moldovan-source` entpackt. Enthält einen Onepager, CSS/JS, zwei Fotos und `marke.svg`; die unbenutzte Datei `Schmetterling_Moldovan.png` ist leer. Formular bereitet nur eine Mailto-Nachricht vor, Rechtstexte fehlen in der Vorlage.
|
||||
|
||||
Angelegt: `var/tagespflege-moldovan.db`, alle 15 Migrationen erfolgreich; separates `var/tagespflege-moldovan-media`; Startkonfiguration `tagespflege-moldovan` auf Port 8089. Über die Installationsoberfläche Website-Grundgerüst angelegt. Bestehende `.env` nach den Installeränderungen bytegleich aus der vorher angelegten Sicherung wiederhergestellt; die neue Installation verwendet Prozessvariablen. Keine Änderungen am CMS-Code durch diese Aufgabe.
|
||||
|
||||
Noch offen: Erstes Administratorkonto im geöffneten Browser unter `http://127.0.0.1:8089/install/konto` fertigstellen und anmelden. Automatische Freigabeprüfung wies zuvor den kombinierten Shellbefehl für Kontoanlage und Serverstart ab (keine genauere Begründung); der verworfene Setuphelfer wurde entfernt und nicht ausgeführt. Server separat erfolgreich gestartet. Danach den eigentlichen Nachbau vollständig im Editor durchführen, Medien hochladen, Desktop/Mobil vergleichen und Speichern/Neuladen prüfen. **Noch kein Vorlagennachbau und keine visuelle Abnahme.**
|
||||
|
||||
## 1.11.4 – Slider-Steuerung auf dem Bild wie bei kant-it.de (14.09.2026)
|
||||
|
||||
**Anlass:** Umut wollte nicht die Knopfleiste unter dem Slider, sondern das Design des Hero-Sliders auf www.kant-it.de (eigenes Plugin, kein Flatsome-Slider): runde weiße Pfeile auf dem Bild, Punkte unten rechts mit aktivem Strich in der Akzentfarbe, keine Zählerleiste, Pfeile auf dem Handy aus.
|
||||
|
||||
**Umgesetzt:** `slider.js` legt Slider und Steuerung in eine `.slider-shell` (beim Aufräumen wieder entpackt) und benennt Pfeile und Pause (`slider-prev/next/play`, `data-playing`); `site.css` zeichnet die Steuerung als Ebene über dem Bild: Pfeile 50 px rund mit Chevron, Punkte 9 px (aktiv 28 px Strich), Pause 32 px unten rechts, Zähler nur noch für Vorlesesoftware, Pfeile unter 600 px ausgeblendet. Neue Gestaltungseigenschaft „Aktiver Punkt“ (`sliderDotActiveColor`, `--slider-dot-active`). Der Compiler-Selektor `#id + .slider-controls` gilt in der Shell weiterhin.
|
||||
|
||||
**Geprüft:** Playwright auf der Pilotkopie bei 1600 und 390 px: Pfeile mittig links/rechts (Desktop) bzw. ausgeblendet (Mobil), Punkte und Pause unten rechts innerhalb des Bildes, Screenshots `reports/editor/slider-design-*.png` visuell geprüft. Zwei gezielte PHP-Tests, 33 JavaScript-Tests, Editor-Browserprüfung vollständig erfolgreich.
|
||||
|
||||
**Veröffentlicht:** Commit `cef710d` und Tag `v1.11.4` auf `origin`; [Release 1.11.4](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.11.4). Weil parallel die Arbeit an „Soziale Profile“ (1.12.0) im selben Arbeitsbaum lief, wurden nur die eigenen Hunks committed und das Archiv (3544 Dateien, 4,6 MB) aus einem sauberen Worktree des Tags gebaut; Anhänge anonym heruntergeladen und bytegenau verglichen, SHA-256 `53904dea…9a663b`.
|
||||
|
||||
## Elektro Bernhard: Abgleich mit dem bestehenden Auftritt, SEO nachgezogen (14.09.2026)
|
||||
|
||||
**Anlass:** Umut bat darum, den Nachbau gegen die laufende Website `elektro-bernhard.de` zu halten und SEO nachzuholen.
|
||||
|
||||
**Dabei zwei Falschangaben gefunden und korrigiert.** Das gelieferte HTML-Paket ordnete zwei Google-Rezensionen falsch zu: „Super Firma und einfach tolle Mitarbeiter“ stammt von **Redzepi Samir** (26.10.2024), nicht von Philipp Bender; Philipp Bender (26.11.2024) hat die Architekten-Rezension geschrieben. Der dritte Eintrag des Pakets war eine selbst formulierte Zusammenfassung statt der tatsächlichen Worte. Die Zuordnung ist am DOM der Live-Seite geprüft (`.wprevpro_t1_DIV_1`: erst Text, dann Name, dann Datum), nicht aus der flachen Textfassung geraten. Jetzt stehen drei Rezensionen im Originalwortlaut mit richtigem Namen und Datum; der Hinweis darunter nennt den Stand und sagt ausdrücklich, dass kein Gesamtdurchschnitt ausgewiesen wird.
|
||||
|
||||
**Aus dem bestehenden Auftritt nachgeholt:** die sechs Stärken („Kundenzufriedenheit“, „Qualität“, „Erfahrung“, „Flexibel“, „Professionell“, „Schnell & sauber“), die das Paket zu drei Absätzen Fließtext zusammengezogen hatte – jetzt als Symbolkarten auf der Leistungsübersicht, die dadurch von 8 auf 23 Bausteine wächst. Dazu der Handlungsaufruf „Projekt geplant? Wir sind bereit.“ als `cta`-Baustein auf der Übersicht und auf allen vier Leistungsseiten. Das Einsatzgebiet nennt jetzt auch Kuppenheim, Gaggenau, Gernsbach und Muggensturm – Orte, die der bestehende Auftritt in seinen eigenen Keywords führt.
|
||||
|
||||
**SEO:** `sameAs` mit den drei Profilen, die die Live-Seite selbst verlinkt (Google-Unternehmensprofil, Facebook, Instagram) – sie stehen jetzt in den strukturierten Daten. Unternehmensbeschreibung auf Leistungen, Notdienstnummer und Bürozeiten erweitert; sie ist zugleich die Zusammenfassungszeile in `llms.txt`. Vorschaubild beim Teilen gesetzt (Startseite Teamfoto, Leistungsseiten Installationsmotiv), womit `og:image` und `twitter:card: summary_large_image` ausgeliefert werden statt einer Textkarte. Meta-Beschreibungen liegen zwischen 110 und 159 Zeichen.
|
||||
|
||||
`llms.txt` gab es schon seit dem Aufbau (Einstellung `llmsTxt`); es listet alle zehn indexierbaren Seiten mit Beschreibung.
|
||||
|
||||
**Bewusst nicht per Skript gesetzt:** der Auszug (`excerpt`) je Seite. Das Feld erscheint in der Verwaltung nur bei Beiträgen; ein per SQL geschriebener Wert wäre unsichtbar und würde beim nächsten Speichern der Seitendaten stillschweigend gelöscht. `llms.txt` nimmt ersatzweise die Seitenbeschreibung, und die ist im Editor bearbeitbar.
|
||||
|
||||
**Offene Lücke gegenüber dem HTML-Paket:** dessen JSON-LD enthielt `openingHoursSpecification`, `areaServed`, einen `contactPoint` für den Notdienst sowie `FAQPage` und `Service`. Das CMS kennt diese Felder nicht – es liefert `LocalBusiness` (Name, Adresse, Telefon, E-Mail, `sameAs`) und `BreadcrumbList`. Das nachzurüsten ist eine Kernerweiterung (Einstellungen, `SiteSettings::organisation()`, FAQ aus Akkordeons ableiten) mit eigenem Release, nicht eine Änderung an dieser Website. Noch nicht umgesetzt, nicht beauftragt.
|
||||
|
||||
**Geprüft:** Alle zehn Adressen 200, `/gibtsnicht` 404, `llms.txt` und `sitemap.xml` ausgeliefert; strukturierte Daten, `og:image` und `twitter:card` im Quelltext kontrolliert; Leistungsübersicht, Leistungsseite und Kundenstimmen in der App-Vorschau angesehen; keine Konsolenfehler. Das größte Dokument (Startseite) liegt bei 117 Bausteinen und 27 KiB – Grenze sind 300 und 256 KiB.
|
||||
|
||||
## 1.11.3 – Farben der Slider-Steuerung (14.09.2026)
|
||||
|
||||
**Anlass:** Umut will die Punktfarben am Slider einstellen; auf dem dunklen Hero waren die Standardfarben (dunkle Punkte, weiße Knöpfe) nicht anpassbar.
|
||||
|
||||
**Umgesetzt:** Drei Gestaltungseigenschaften in der neuen Gruppe „Slider-Steuerung“ (`sliderDotColor`, `sliderControlColor`, `sliderControlBackground`) mit dem Ziel `controls`: der Compiler schreibt sie als `--slider-dot`, `--slider-ink`, `--slider-bg` auf den Slider und auf die vom Skript dahinter eingefügte Steuerleiste (`#id,#id + .slider-controls{…}`); `site.css` liest die Variablen für Punkte, Pfeile, Zähler, Knopfhintergrund und die skriptfreien `::scroll-marker`. Die Gruppe erscheint nur an Slidern (`relevantProperty`).
|
||||
|
||||
**Geprüft:** Neuer Test `testSliderControlsTakeTheirColoursFromTheSliderStyle` (Ausgabe mit Hex- und Palettenfarbe). Playwright im Editor der Pilotkopie: Gruppe am Hero-Slider vorhanden, Punktfarbe `#ff0000` gesetzt, Regel in der Vorschau angekommen, Punkt bei „Bewegung abspielen“ rot gezeichnet. 33 JavaScript-Tests. Editor-Browserprüfung siehe Veröffentlichung.
|
||||
**Geprüft:** Neuer Test `testSliderControlsTakeTheirColoursFromTheSliderStyle` (Ausgabe mit Hex- und Palettenfarbe). Playwright im Editor der Pilotkopie: Gruppe am Hero-Slider vorhanden, Punktfarbe `#ff0000` gesetzt, Regel in der Vorschau angekommen, Punkt bei „Bewegung abspielen“ rot gezeichnet. 33 JavaScript-Tests, Editor-Browserprüfung vollständig erfolgreich.
|
||||
|
||||
**Veröffentlicht:** Commit `6dc30a4` und Tag `v1.11.3` auf `origin`; [Release 1.11.3](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.11.3) mit ZIP (3548 Dateien), SHA-256-Datei und `install.php` als neuestes stabiles Release; Anhänge anonym heruntergeladen und bytegenau verglichen, SHA-256 `a9536289…f45a24`. Der Commit enthält auch den parallel entstandenen Statuseintrag zur Elektro-Bernhard-Referenzwebsite; deren Skripte sind noch nicht committed.
|
||||
|
||||
## Zweite Referenzwebsite: Elektro Bernhard in eigener Datenbank (14.09.2026)
|
||||
|
||||
@@ -494,7 +1376,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
## Bekannte Einschränkungen
|
||||
|
||||
- Gestaltung über geprüfte Tokens, keine beliebigen Pixelwerte, eigenen Schriften oder freien Farben. Noch keine Inline-Textbearbeitung, Formulare, dynamischen Menüs, Plugin-/Themeverwaltung oder WordPress-Import. Abschnittsvorlagen sind lokale Blockrezepte, keine globale Templateverwaltung.
|
||||
- Mediathek derzeit für statische Bilder: keine Videos/PDFs/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
|
||||
- Mediathek derzeit für statische Bilder und PDFs (seit 22.09.2026): keine Videos/SVGs, Lightbox/Slideshow, freie Bildzuschnitte, Ordner, Paginierung oder physische Löschung. Bild-URLs sind öffentlich; Archivieren schützt keine vertraulichen Dateien. Originale werden durch optimierte Varianten ersetzt. Einblendungen starten beim Laden, nicht beim Scrollen.
|
||||
- Sektionen bieten die vorhandenen Farb-/Breitentokens geräteübergreifend. Benutzerverwaltung und Passwortänderung sind noch nicht als Browseroberfläche verfügbar.
|
||||
- **Der Aufgabenplan ist von Anfragen abhängig.** Ohne Besucher und ohne geöffnetes Backend läuft nichts; minutengenau ist nur ein echter Cronjob oder der URL-Cronjob des Hosters. Unter Apache mit mod_php belegt eine laufende Aufgabe einen PHP-Prozess, nachdem die Seite beim Besucher ist (dort gibt es kein `fastcgi_finish_request()`) – deshalb sind die Grenzen je Seitenaufruf klein (2 Aufgaben, 20 Sekunden) und der Auslöser abschaltbar. Die Sicherung ist die schwerste Aufgabe und läuft trotzdem mit: eine tägliche Sicherung ist mehr wert als keine. **Nicht geprüft:** das Verhalten unter mod_php und unter echter Last – hier lief alles gegen den eingebauten PHP-Server.
|
||||
- **Offene redaktionelle Feststellung:** `/grundlagen` trägt einen im Editor gesetzten Textblock in `#d08080` auf hellem Grund – 2,75:1, unter der AA-Schwelle. Die Farbe wurde **nicht** verändert, weil sie eine Nutzerentscheidung ist; die Prüfung nennt sie bei jedem Lauf, und die Kontrastwarnung im Editor meldet sie ebenfalls.
|
||||
|
||||
+5
-1
@@ -124,7 +124,7 @@ var/releases/install.php → Release-Anhang
|
||||
- **Die Datenbank wird nur zurückgeschrieben, wenn die Migration schon begonnen hatte.** Sonst würde der Rückweg Inhalte verlieren, die während des Updates entstanden sind.
|
||||
- **Der Rauchtest ist `cms:health`** und läuft als eigener Prozess, weil nur ein frischer Prozess den neuen Code ausführt.
|
||||
- **Noch offen:** MySQL wird gesichert, indem `mysqldump` aufgerufen wird – das ist **nicht** umgesetzt, weil noch keine MySQL-Installation existiert (Arbeitspaket J). Bei einer Datenbank, die nicht als Datei vorliegt, sichert der Updater derzeit nur die Umgebungsdateien und sagt das im Schritt „Sicherung“. Vor dem ersten Livegang mit MySQL muss das nachgezogen werden.
|
||||
- **Ebenfalls offen:** der Installer aus Arbeitspaket N. Die Systemprüfung, die er braucht, existiert jetzt.
|
||||
- **Inzwischen umgesetzt:** der Installer (`/install`, ADR 041) und der Weg ohne Git über das Releasearchiv (`scripts/install.php`, System → Update aus dem Archiv). Offen bleibt allein die MySQL-Sicherung.
|
||||
|
||||
## Übergang auf 1.7.0: `.env` gehört zur Installation
|
||||
|
||||
@@ -133,3 +133,7 @@ Der Archiv-Updater erhält die Konfiguration auch bei diesem Übergang. Der erst
|
||||
In alten Git-Installationen war `.env` noch versioniert. Beim erstmaligen Wechsel kann ein alter Git-Updater diese neue Schutzlogik noch nicht besitzen. Nutze dafür den Archiv-Updater. Bei einem manuellen Git-Checkout zuerst `.env` und gegebenenfalls `.env.local` außerhalb des Checkout-Verzeichnisses sichern, beide nach dem Checkout wiederherstellen und erst danach Setup/Migration/Cache starten. Ab 1.7.0 erhält der Git-Updater die Dateien selbst vor dem ersten neuen PHP-Prozess. `.env` nicht erneut ins Repository aufnehmen.
|
||||
|
||||
Bei FTP-Updates die `.env` aus dem neuen Archiv überspringen. Die Vorlage im Archiv ist ausschließlich für Erstinstallationen bestimmt. Ein Rückweg benötigt den alten Code plus die beiden gesicherten Konfigurationsdateien.
|
||||
|
||||
## MySQL und MariaDB (seit 16.09.2026, ADR 104)
|
||||
|
||||
Sicherung und Wiederherstellung brauchen auf einer MySQL-Installation die Clients `mysqldump` und `mysql` im Pfad des PHP-Prozesses (auf geteiltem Hosting beim Anbieter erfragen; ein voller Pfad lässt sich nicht eintragen, aber `mysqldump.phar`/`mysql.phar` im Projektstamm würden gefunden). Die Sicherung schreibt `datenbank.sql` mit `--single-transaction`; die Wiederherstellung spielt sie mit `mysql --execute=source` ein. Fehlt der Client zum Einspielen, sagt die Wiederherstellung das und nennt den Pfad des Dumps. Der Archiv-Updater und der Git-Updater sichern vorher über denselben Weg und spielen beim Rückweg dieselbe Sicherung ein. Beide Wege sperren sich gegenseitig über `var/update.lock`; ein zweiter Aufruf bekommt die Meldung „Ein anderes Update läuft gerade“.
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260915000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Plugins: eine Zeile je Erweiterung mit dem Schalter, ob sie aktiv ist.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Was ein Plugin kann, steht in seinem Manifest im Ordner plugins/. Hier steht nur, ob der
|
||||
// Betreiber es eingeschaltet hat - eine Entscheidung der Installation, nicht des Codes,
|
||||
// und deshalb in der Datenbank statt in einer Datei, die ein Update ueberschreiben koennte.
|
||||
$plugin = $schema->createTable('plugin');
|
||||
$plugin->addColumn('name', 'string', ['length' => 40]);
|
||||
$plugin->addColumn('active', 'boolean', ['default' => false]);
|
||||
$plugin->addColumn('activated_at', 'datetime_immutable', ['notnull' => false]);
|
||||
// Die Fassung, mit der es zuletzt eingeschaltet wurde - damit die Verwaltung sagen kann,
|
||||
// dass ein Plugin seit dem Einschalten aktualisiert wurde.
|
||||
$plugin->addColumn('version', 'string', ['length' => 32, 'default' => '']);
|
||||
$plugin->setPrimaryKey(['name']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->dropTable('plugin');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260916000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Plugins: die Einstellungen je Erweiterung, wie sie der Betreiber in der Verwaltung setzt.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Was ein Plugin einstellbar macht, steht in seinem Manifest; was der Betreiber gewaehlt
|
||||
// hat, hier - als JSON, weil jedes Plugin andere Schluessel hat. Eine Spalte, kein
|
||||
// Tabellenneubau: auf SQLite ist ADD COLUMN eine Zeile im Katalog.
|
||||
$schema->getTable('plugin')->addColumn('settings', 'text', ['default' => '{}']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->getTable('plugin')->dropColumn('settings');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260917000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Dokumente: PDFs in der Mediathek, zum Verlinken und fuer den Betrachter.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Eine eigene Tabelle statt einer Art-Spalte in media: jede Stelle, die ein Bild sucht,
|
||||
// erwartet Varianten mit Breite und Hoehe. Ein PDF dort waere ein Bild ohne Bild - an
|
||||
// einem Dutzend Stellen. Getrennt kann keine Bildauswahl je ein Dokument finden.
|
||||
$document = $schema->createTable('document');
|
||||
$document->addColumn('id', 'string', ['length' => 32]);
|
||||
$document->addColumn('title', 'string', ['length' => 160]);
|
||||
$document->addColumn('bytes', 'integer');
|
||||
$document->addColumn('archived', 'boolean', ['default' => false]);
|
||||
$document->addColumn('version', 'integer', ['default' => 1]);
|
||||
$document->addColumn('created_at', 'datetime_immutable');
|
||||
$document->setPrimaryKey(['id']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->dropTable('document');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20261009000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Mehrsprachigkeit: die öffentlichen Adressen der übersetzten Seiten (ADR 111).';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Die Texte einer Übersetzung stehen im Dokument der Seite und laufen so mit Entwurf,
|
||||
// Veröffentlichung und Fassungen mit. Hier steht nur, was die Website zum Auffinden
|
||||
// braucht: unter welcher Adresse eine Seite in welcher Sprache veröffentlicht ist. Die
|
||||
// Zeilen entstehen beim Veröffentlichen und spiegeln genau den öffentlichen Stand.
|
||||
// Eine neue Tabelle, keine Änderung an `page`: auf SQLite baut jede Indexänderung an einer
|
||||
// bestehenden Tabelle sie neu auf (siehe ADR 010).
|
||||
$table = $schema->createTable('page_translation');
|
||||
$table->addColumn('page_id', 'string', ['length' => 32]);
|
||||
$table->addColumn('website_id', 'string', ['length' => 32]);
|
||||
$table->addColumn('locale', 'string', ['length' => 12]);
|
||||
$table->addColumn('slug', 'string', ['length' => 80]);
|
||||
$table->addColumn('path', 'string', ['length' => 400]);
|
||||
$table->setPrimaryKey(['page_id', 'locale']);
|
||||
$table->addUniqueIndex(['website_id', 'locale', 'path'], 'uniq_page_translation_path');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Die Übersetzungen selbst bleiben in den Dokumenten; ohne diese Tabelle sind sie nur
|
||||
// nicht mehr öffentlich erreichbar.
|
||||
$schema->dropTable('page_translation');
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -5,6 +5,9 @@
|
||||
"scripts": {
|
||||
"build:buttons": "node scripts/build-buttons.mjs",
|
||||
"test:buttons": "node scripts/button-check.mjs",
|
||||
"test:button-icons": "node scripts/button-icon-check.mjs",
|
||||
"test:menu-buttons": "node scripts/menu-button-check.mjs",
|
||||
"build:icons": "node scripts/build-icons.mjs",
|
||||
"test:navigation-ux": "node scripts/navigation-ux-check.mjs",
|
||||
"test:responsive-navigation": "node scripts/responsive-navigation-check.mjs",
|
||||
"build:design": "node scripts/build-design.mjs",
|
||||
@@ -20,7 +23,9 @@
|
||||
"test:link-media": "node scripts/link-media-check.mjs",
|
||||
"test:404": "node scripts/not-found-check.mjs",
|
||||
"test:colors": "node scripts/color-picker-check.mjs",
|
||||
"test:accordion": "node scripts/accordion-check.mjs"
|
||||
"test:accordion": "node scripts/accordion-check.mjs",
|
||||
"test:motion": "node scripts/motion-check.mjs",
|
||||
"test:archive-update": "node scripts/archive-update-check.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.10.0",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/* Bildvergleich. Die Position kommt als --pos (Prozent) vom Skript; ohne Skript gilt die Mitte. */
|
||||
.block-bildvergleich{--pos:50%;margin:0}
|
||||
.bildvergleich-buehne{position:relative;margin:0;overflow:hidden;user-select:none;-webkit-user-select:none}
|
||||
.bildvergleich-rund .bildvergleich-buehne{border-radius:12px}
|
||||
.bildvergleich-buehne img{display:block;width:100%;height:auto;border-radius:0}
|
||||
.bildvergleich-vorher{position:absolute;inset:0;height:100%;object-fit:cover;clip-path:inset(0 calc(100% - var(--pos)) 0 0)}
|
||||
.bildvergleich-linie{position:absolute;top:0;bottom:0;left:var(--pos);width:2px;margin-left:-1px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.25);pointer-events:none}
|
||||
.bildvergleich-linie::after{content:'⇔';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:44px;height:44px;border-radius:50%;background:#fff;color:#182d2b;display:grid;place-items:center;font-size:20px;box-shadow:0 4px 14px rgba(0,0,0,.3)}
|
||||
.bildvergleich-etikett{position:absolute;top:14px;padding:6px 10px;border-radius:6px;background:rgba(0,0,0,.55);color:#fff;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;pointer-events:none}
|
||||
.bildvergleich-etikett-vorher{left:14px}
|
||||
.bildvergleich-etikett-nachher{right:14px}
|
||||
.bildvergleich-regler{position:absolute;inset:0;display:block;cursor:ew-resize}
|
||||
.bildvergleich-regler input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:ew-resize}
|
||||
.bildvergleich-regler input:focus-visible{opacity:1;appearance:none;-webkit-appearance:none;background:transparent;outline:3px solid #fff;outline-offset:-6px}
|
||||
.block-bildvergleich .block-unavailable{margin:0}
|
||||
/* Griff als Punkt statt Pfeile (Plugin-Einstellung). */
|
||||
.bildvergleich-griff-punkt .bildvergleich-linie::after{content:'';width:22px;height:22px}
|
||||
/* Die Vergleichsgalerie: ein Raster aus Bildvergleichen. */
|
||||
.block-vergleichsgalerie{margin:0}
|
||||
.vergleichsgalerie-titel{margin:0 0 18px}
|
||||
.vergleichsgalerie-raster{display:grid;gap:20px;grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.vergleichsgalerie-3 .vergleichsgalerie-raster{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
@media(max-width:767px){.vergleichsgalerie-raster,.vergleichsgalerie-3 .vergleichsgalerie-raster{grid-template-columns:1fr}}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Bildvergleich: der Regler setzt --pos auf der Bühne. Ohne dieses Skript steht der Vergleich
|
||||
* bei der gespeicherten Startposition; Tastatur und Vorlesesoftware bedienen das native
|
||||
* Bereichsfeld in beiden Fällen.
|
||||
*/
|
||||
(() => {
|
||||
for (const block of document.querySelectorAll('[data-bildvergleich]')) {
|
||||
const range = block.querySelector('input[type="range"]');
|
||||
if (!range) continue;
|
||||
const apply = () => block.style.setProperty('--pos', `${range.value}%`);
|
||||
range.addEventListener('input', apply);
|
||||
apply();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,24 @@
|
||||
{# Bildvergleich: das Nachher-Bild liegt unten, das Vorher-Bild darüber und wird per clip-path
|
||||
auf den linken Teil beschnitten. Der Regler ist ein natives Bereichsfeld – ohne Skript bleibt
|
||||
er stehen, beide Bilder sind trotzdem zu sehen. Die Vorlage bekommt nur, was der Kern geprüft
|
||||
hat: `props` nach dem Schema, `settings` des Plugins, `block.id`, `designClasses`. #}
|
||||
{% set vorher = props.before ? kant_plugin_media(props.before) : null %}
|
||||
{% set nachher = props.after ? kant_plugin_media(props.after) : null %}
|
||||
{% set labelVorher = props.labelBefore ?: settings.labelBefore|default('Vorher') %}
|
||||
{% set labelNachher = props.labelAfter ?: settings.labelAfter|default('Nachher') %}
|
||||
<div id="{{ block.id }}" class="block-bildvergleich{{ props.rounded ? ' bildvergleich-rund' : '' }} bildvergleich-griff-{{ settings.handle|default('pfeile') }}{{ designClasses }}" data-bildvergleich data-position="{{ props.position }}">
|
||||
{% if vorher and nachher %}
|
||||
{% set gross = nachher.variants|last %}
|
||||
<figure class="bildvergleich-buehne">
|
||||
<img class="bildvergleich-nachher" src="{{ gross.src }}" srcset="{% for v in nachher.variants %}{{ v.src }} {{ v.width }}w{{ loop.last ? '' : ', ' }}{% endfor %}" sizes="(max-width: 1320px) 100vw, 1320px" width="{{ gross.width }}" height="{{ gross.height }}" alt="{{ props.alt ?: nachher.alt }}" loading="lazy" decoding="async">
|
||||
{% set grossVorher = vorher.variants|last %}
|
||||
<img class="bildvergleich-vorher" src="{{ grossVorher.src }}" srcset="{% for v in vorher.variants %}{{ v.src }} {{ v.width }}w{{ loop.last ? '' : ', ' }}{% endfor %}" sizes="(max-width: 1320px) 100vw, 1320px" width="{{ grossVorher.width }}" height="{{ grossVorher.height }}" alt="" loading="lazy" decoding="async">
|
||||
<span class="bildvergleich-etikett bildvergleich-etikett-vorher" aria-hidden="true">{{ labelVorher }}</span>
|
||||
<span class="bildvergleich-etikett bildvergleich-etikett-nachher" aria-hidden="true">{{ labelNachher }}</span>
|
||||
<span class="bildvergleich-linie" aria-hidden="true"></span>
|
||||
<label class="bildvergleich-regler"><span class="visually-hidden">{{ labelVorher }} und {{ labelNachher }} vergleichen: Regler verschieben</span><input type="range" min="0" max="100" value="{{ props.position }}" step="1"></label>
|
||||
</figure>
|
||||
{% else %}
|
||||
<p class="block-unavailable">{{ editing ? 'Bildvergleich: bitte unter Inhalt ein Bild für vorher und eines für nachher wählen.' : '' }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
{# Die Vergleichsgalerie: ein Container. Seine Kinder kommen fertig gerendert als `children`
|
||||
herein - der Kern hat sie mit demselben Kontext gerendert wie jeden anderen Baustein. #}
|
||||
<div id="{{ block.id }}" class="block-vergleichsgalerie vergleichsgalerie-{{ props.columns }}{{ designClasses }}">
|
||||
{% if props.title %}<h3 class="vergleichsgalerie-titel">{{ props.title }}</h3>{% endif %}
|
||||
{% if children|trim %}
|
||||
<div class="vergleichsgalerie-raster">{{ children }}</div>
|
||||
{% elseif editing %}
|
||||
<p class="block-unavailable">Vergleichsgalerie: Bildvergleiche hier hineinziehen.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "vergleich",
|
||||
"title": "Bildvergleich",
|
||||
"description": "Vorher und nachher in einem Bild: ein Regler schiebt zwischen zwei Aufnahmen – für Renovierungen, Malerarbeiten, Sanierungen.",
|
||||
"version": "1.1.0",
|
||||
"requires": "1.17.0",
|
||||
"settings": {
|
||||
"labelBefore": { "kind": "text", "label": "Standardbeschriftung vorher", "default": "Vorher", "maxLength": 40 },
|
||||
"labelAfter": { "kind": "text", "label": "Standardbeschriftung nachher", "default": "Nachher", "maxLength": 40 },
|
||||
"handle": { "kind": "select", "label": "Griff des Reglers", "choices": { "pfeile": "Pfeile", "punkt": "Punkt" }, "default": "pfeile" }
|
||||
},
|
||||
"blocks": {
|
||||
"bildvergleich": {
|
||||
"label": "Bildvergleich",
|
||||
"description": "Zwei Bilder übereinander, ein Regler dazwischen.",
|
||||
"icon": "⇔",
|
||||
"template": "blocks/bildvergleich.html.twig",
|
||||
"css": "assets/bildvergleich.css",
|
||||
"script": "assets/bildvergleich.js",
|
||||
"props": {
|
||||
"before": { "kind": "media", "label": "Bild vorher" },
|
||||
"after": { "kind": "media", "label": "Bild nachher" },
|
||||
"labelBefore": { "kind": "text", "label": "Beschriftung vorher", "help": "Leer: die Standardbeschriftung aus den Plugin-Einstellungen.", "maxLength": 40 },
|
||||
"labelAfter": { "kind": "text", "label": "Beschriftung nachher", "maxLength": 40 },
|
||||
"alt": { "kind": "text", "label": "Was die Bilder zeigen", "help": "Ein Satz für Vorlesesoftware, zum Beispiel „Fassade vor und nach dem Anstrich“.", "maxLength": 200 },
|
||||
"position": { "kind": "number", "label": "Startposition des Reglers (%)", "min": 5, "max": 95, "default": 50 },
|
||||
"rounded": { "kind": "boolean", "label": "Abgerundete Ecken", "default": true }
|
||||
}
|
||||
},
|
||||
"galerie": {
|
||||
"label": "Vergleichsgalerie",
|
||||
"description": "Mehrere Bildvergleiche nebeneinander im Raster.",
|
||||
"icon": "⊞",
|
||||
"template": "blocks/galerie.html.twig",
|
||||
"css": "assets/bildvergleich.css",
|
||||
"container": true,
|
||||
"children": ["vergleich.bildvergleich"],
|
||||
"props": {
|
||||
"columns": { "kind": "select", "label": "Spalten am Schreibtisch", "choices": { "2": "Zwei", "3": "Drei" }, "default": "2" },
|
||||
"title": { "kind": "text", "label": "Überschrift der Galerie", "maxLength": 120 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"presets": {
|
||||
"abschnitt": {
|
||||
"label": "Vorher-Nachher-Abschnitt",
|
||||
"description": "Eine Sektion mit Überschrift, kurzem Text und einem Bildvergleich.",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "vergleich-abschnitt", "type": "section", "layout": { "tone": "paper", "width": "normal" },
|
||||
"children": [
|
||||
{ "id": "vergleich-titel", "type": "heading", "props": { "text": "Vorher und nachher", "level": 2 } },
|
||||
{ "id": "vergleich-text", "type": "text", "props": { "text": "Ziehen Sie den Regler und sehen Sie den Unterschied." } },
|
||||
{ "id": "vergleich-bild", "type": "vergleich.bildvergleich", "props": { "before": "", "after": "", "labelBefore": "", "labelAfter": "", "alt": "", "position": 50, "rounded": true } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"routes": {
|
||||
"daten": { "path": "/vergleich/daten", "template": "routes/daten.json.twig", "type": "json" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{# Eine eigene Adresse des Plugins: die Einstellungen als JSON - fuer eine App oder ein Skript,
|
||||
das die Beschriftungen kennen will. Die Vorlage bekommt `settings`, `query` und `route`. #}
|
||||
{{ {plugin: 'vergleich', labels: {before: settings.labelBefore, after: settings.labelAfter}, handle: settings.handle, echo: query}|json_encode(constant('JSON_UNESCAPED_UNICODE') b-or constant('JSON_UNESCAPED_SLASHES'))|raw }}
|
||||
+10
-7
@@ -2,7 +2,14 @@ DirectoryIndex index.php
|
||||
Options -Indexes -MultiViews
|
||||
|
||||
# Alles, was keine Datei ist, beantwortet PHP.
|
||||
#
|
||||
# Die Zeile fuer index.php selbst ist kein Zierrat: Bei IONOS erkennt !-f die bereits
|
||||
# umgeschriebene index.php nicht als Datei, die Regel greift wieder und wieder, und Apache bricht
|
||||
# nach zehn Runden mit einem weissen 500er ab (huefner-gartenbau.de, 12.10.2026). Dieselbe
|
||||
# Absicherung steht in der .htaccess von WordPress.
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
RewriteRule ^index\.php$ - [L]
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
|
||||
@@ -14,13 +21,9 @@ RewriteRule ^ index.php [L]
|
||||
#RewriteCond %{HTTP:X-Forwarded-Proto} !=https
|
||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||
|
||||
# Eine Domain, eine Adresse. Ohne diese Umleitung ist dieselbe Seite unter www und ohne www
|
||||
# erreichbar - zwei Adressen fuer denselben Inhalt, und Suchmaschinen muessen raten, welche gilt.
|
||||
# Die Richtung muss zu SITE_ORIGIN passen; hier die Fassung "immer mit www".
|
||||
#RewriteCond %{HTTP_HOST} !^www\. [NC]
|
||||
#RewriteCond %{HTTP_HOST} !^localhost [NC]
|
||||
#RewriteCond %{HTTP_HOST} !^127\.0\.0\.1 [NC]
|
||||
#RewriteRule ^ https://www.%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||
# Eine Domain, eine Adresse - mit oder ohne www. Das erledigt die Anwendung selbst nach der
|
||||
# Einstellung SITE_ORIGIN in .env: die jeweils andere Schreibweise antwortet mit 301 dorthin.
|
||||
# Hier braucht es dafuer keine Regel; eine zweite Stelle wuerde nur auseinanderlaufen.
|
||||
|
||||
# Uebertragen wird komprimiert. HTML, CSS, JavaScript und XML schrumpfen dabei auf etwa ein
|
||||
# Viertel; Bilder und Schriften sind bereits komprimiert und bleiben aussen vor.
|
||||
|
||||
+412
-69
@@ -70,8 +70,13 @@ textarea { resize: vertical; min-height: 110px; line-height: 1.6 }
|
||||
label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
.field-help { font-size: 11px; line-height: 1.55; color: var(--muted); margin: 7px 0 0 }
|
||||
.field-help code, .card code { background: #eef2ea; padding: 1px 5px; border-radius: 4px; font-size: .95em }
|
||||
.check-label { display: flex; align-items: center; gap: 8px; font-weight: 500 }
|
||||
/* Nur Häkchen und Text sind klickbar: über die volle Breite schaltete ein Klick in die leere Mitte
|
||||
der Zeile das Häkchen um - in der Menüverwaltung klappten so die Button-Einstellungen zu. */
|
||||
.check-label { display: flex; align-items: center; gap: 8px; font-weight: 500; width: fit-content; max-width: 100% }
|
||||
.check-label input { width: 16px; min-height: 16px }
|
||||
/* Eine Gruppe von Häkchen nebeneinander, die auf schmalen Bildschirmen umbricht (Sprachen). */
|
||||
.check-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px 20px; border: 0; padding: 0; margin: 0 0 12px; min-width: 0 }
|
||||
.check-grid legend { font-weight: 600; margin-bottom: 8px; padding: 0 }
|
||||
/* A grid of fields that becomes one column when there is no room. */
|
||||
.fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 24px }
|
||||
.fields > div > label:first-child, .field > label:first-child { margin-top: 14px }
|
||||
@@ -147,31 +152,61 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
.nav-icon { width: 18px; height: 18px; color: var(--shell-muted); flex-shrink: 0 }
|
||||
.nav-out::after { content: '↗'; margin-left: auto; color: var(--shell-muted); font-size: 11px }
|
||||
|
||||
.shell-foot { margin-top: auto; padding-top: 20px; border-top: 1px solid #2a3d35; display: grid; gap: 6px }
|
||||
.shell-user { display: flex; align-items: center; gap: 10px; padding: 8px 10px; font-size: 12px }
|
||||
.shell-user strong { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
/* Der Fuss der Leiste: Kontokarte, Abmeldesymbol, Fassung. Eine Einheit statt dreier Zeilen. */
|
||||
.shell-foot { margin-top: auto; padding-top: 14px; border-top: 1px solid #2a3d35; display: grid; gap: 8px }
|
||||
.shell-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px;
|
||||
border-radius: 13px;
|
||||
background: #ffffff0b;
|
||||
border: 1px solid #ffffff10;
|
||||
}
|
||||
.shell-user {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 9px;
|
||||
text-decoration: none;
|
||||
color: var(--shell-ink);
|
||||
}
|
||||
.shell-user:hover, .shell-user[aria-current=page] { background: #ffffff12 }
|
||||
.shell-user[aria-current=page] .shell-avatar { box-shadow: 0 0 0 2px var(--mint) }
|
||||
.shell-user-text { display: grid; min-width: 0; line-height: 1.3 }
|
||||
.shell-user-text strong { font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.shell-user-text span { font-size: 11px; color: var(--shell-muted) }
|
||||
.shell-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px; height: 28px;
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #2b4a3b;
|
||||
color: var(--mint);
|
||||
font-size: 12px;
|
||||
background: var(--mint);
|
||||
color: var(--shell);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.shell-foot form { margin: 0 }
|
||||
.shell-foot button {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
.shell-logout {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px; height: 38px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--shell-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
min-height: 34px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.shell-foot button:hover { background: #21362d; color: var(--shell-ink) }
|
||||
.shell-logout .nav-icon { color: inherit }
|
||||
.shell-logout:hover { background: #ffffff14; color: #fff }
|
||||
.shell-logout:focus-visible { outline-color: var(--mint); outline-offset: -2px }
|
||||
.shell-version { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: 0; padding: 0 8px 2px; font-size: 11px; color: var(--shell-muted) }
|
||||
.shell-version a { text-decoration: none }
|
||||
.shell-version > a:hover { color: var(--shell-ink); text-decoration: underline }
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
@@ -211,13 +246,37 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
.shell-heading p { font-size: 13px; margin: 7px 0 0; max-width: 62ch }
|
||||
.shell-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap }
|
||||
.shell-body { padding: 30px 38px 60px; max-width: 1180px; width: 100% }
|
||||
.stack { display: grid; gap: 20px }
|
||||
/* Eine Spalte, die nie breiter wird als der Bildschirm: ohne `minmax(0, 1fr)` waechst
|
||||
die automatische Rasterspalte auf die Mindestbreite ihres breitesten Inhalts - auf dem
|
||||
Handy schiebt eine einzige lange Pfadangabe dann die ganze Seite zur Seite. */
|
||||
.stack { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px }
|
||||
|
||||
.notice { padding: 13px 17px; border-radius: 8px; line-height: 1.6; font-size: 13px; margin: 0 }
|
||||
.notice.ok { background: #edf3dc; color: #2c5340; border: 1px solid #c5d89a }
|
||||
.error, .notice.error { background: #fff0eb; color: var(--danger); border: 1px solid #e2b2a7 }
|
||||
.shell-body > .notice { margin-bottom: 4px }
|
||||
|
||||
/* Der Hinweisstreifen ueber jedem Verwaltungsbildschirm: eine Sache, die getan werden will, und
|
||||
gleich daneben der Knopf, der sie tut. Warm statt rot - es ist eine Aufgabe, kein Fehler. */
|
||||
.shell-notice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px 18px;
|
||||
margin: 0 0 4px;
|
||||
padding: 12px 14px 12px 17px;
|
||||
border: 1px solid #e3cf9b;
|
||||
border-radius: 10px;
|
||||
background: #fdf6e4;
|
||||
color: #5b4412;
|
||||
line-height: 1.6;
|
||||
font-size: 13px;
|
||||
}
|
||||
.shell-notice > span { min-width: 0; flex: 1 1 260px }
|
||||
.shell-notice .secondary { flex: none; border-color: #dcc48a; background: var(--paper); color: #5b4412 }
|
||||
.shell-notice .secondary:hover { background: #fbeecd; border-color: #cbb072 }
|
||||
|
||||
/* ---- cards and sections ---------------------------------------------------------------- */
|
||||
|
||||
.card { background: var(--paper); border: 1px solid var(--line); border-radius: 12px }
|
||||
@@ -232,7 +291,12 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
}
|
||||
.card-head h2, .card-head h3 { margin: 0 }
|
||||
.card-head .muted { font-size: 12px }
|
||||
.card-body { padding: 8px 22px 24px }
|
||||
.card-body { padding: 8px 22px 24px; min-width: 0 }
|
||||
.card-body code { overflow-wrap: anywhere }
|
||||
/* Das Kleingedruckte einer Karte: zugeklappt, damit auf einem Handy der Knopf oben bleibt. */
|
||||
.hint { margin: 12px 0 0 }
|
||||
.hint > summary { font-size: 12px; font-weight: 600; color: var(--muted); cursor: pointer; min-height: 44px; display: flex; align-items: center }
|
||||
.hint[open] > summary { margin-bottom: 2px }
|
||||
/* A heading for a group of rows, outside any card. */
|
||||
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px 20px; flex-wrap: wrap; margin: 16px 2px -6px }
|
||||
.section-head h2 { margin: 0 }
|
||||
@@ -428,7 +492,7 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.table .check-cell input { width: 18px; min-height: 18px }
|
||||
.table-new input, .table-new select { background: #fafbf8 }
|
||||
.table form { margin: 0 }
|
||||
/* Column widths per screen: the two tables carry different things. */
|
||||
/* Column widths per screen: the menu table carries text, links and switches. */
|
||||
.menu-table td:first-child input { width: 82px }
|
||||
.menu-table td:nth-child(2) select { width: 148px }
|
||||
.menu-table td:nth-child(3) input { min-width: 170px }
|
||||
@@ -436,15 +500,6 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.menu-table td:nth-child(5) select { min-width: 190px }
|
||||
.menu-table td:nth-child(6) input { width: 130px }
|
||||
.menu-table td:nth-child(7) input { min-width: 190px }
|
||||
.field-table td:first-child input { width: 140px }
|
||||
.field-table td:nth-child(2) select { width: 150px }
|
||||
.field-table td:nth-child(3) input { min-width: 170px }
|
||||
.field-table td:nth-child(5) input { min-width: 180px }
|
||||
.field-table td:nth-child(6) input { min-width: 170px }
|
||||
.theme-table td input { width: 190px }
|
||||
.theme-table td select { width: 150px }
|
||||
.theme-table td input[type=color] { width: 52px }
|
||||
.theme-table .weights-cell { white-space: nowrap }
|
||||
.redirect-table td { padding: 8px 12px 8px 0; font-size: 12px }
|
||||
/* Der Aufgabenplan: die erste Spalte traegt Text, die schmalen tragen Zahlen und Schalter. */
|
||||
.tasks-table td:first-child { min-width: 220px; max-width: 340px }
|
||||
@@ -456,12 +511,12 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.card-body .section-head:first-child { margin-top: 14px }
|
||||
.card-body > .primary, .card-body > .secondary { margin-top: 24px }
|
||||
|
||||
/* A screen made of several equal sections (menu slots, form definitions). */
|
||||
/* A screen made of several equal sections (menu slots). */
|
||||
.stack-section { display: grid; gap: 10px }
|
||||
.menu-form > label:first-of-type, .form-def > label:first-of-type { margin-top: 16px }
|
||||
.menu-form > label:first-of-type { margin-top: 16px }
|
||||
.menu-form > input[name=name] { max-width: 420px }
|
||||
.menu-form .table, .form-def .table { margin-top: 22px }
|
||||
.menu-form .primary, .form-def .primary { margin-top: 20px }
|
||||
.menu-form .table { margin-top: 22px }
|
||||
.menu-form .primary { margin-top: 20px }
|
||||
|
||||
.empty {
|
||||
padding: 34px;
|
||||
@@ -479,10 +534,6 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.contrast-ok { background: #edf3dc; color: #2c5340; border: 1px solid #c5d89a }
|
||||
.contrast-warn { background: #fff6e2; color: #6f4e18; border: 1px solid #e6cf9c }
|
||||
.contrast-bad { background: #fff0eb; color: var(--danger); border: 1px solid #e2b2a7 }
|
||||
.theme-form .theme-swatch { width: 52px; min-height: 38px; padding: 2px; cursor: pointer }
|
||||
.theme-form .weights-cell { white-space: nowrap }
|
||||
.theme-form .weights-cell label { display: inline-flex; align-items: center; gap: 4px; margin: 0 9px 0 0; font-size: 11px; font-weight: 400 }
|
||||
.theme-form .weights-cell input { width: 14px; min-height: 14px }
|
||||
|
||||
/* ---- submissions ----------------------------------------------------------------------- */
|
||||
|
||||
@@ -518,25 +569,33 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
/* ---- narrow screens: the sidebar becomes a strip above the content --------------------- */
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.admin-shell { grid-template-columns: 1fr }
|
||||
/* minmax(0, 1fr), nicht 1fr: sonst nimmt die Spalte die Mindestbreite der Leiste an und
|
||||
schiebt die ganze Seite ueber den rechten Rand hinaus. */
|
||||
.admin-shell { grid-template-columns: minmax(0, 1fr) }
|
||||
.shell-nav {
|
||||
position: static;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow: visible;
|
||||
gap: 6px;
|
||||
padding: 11px 14px;
|
||||
}
|
||||
/* Nur die Liste wischt; Konto und Abmelden bleiben rechts im Bild. */
|
||||
.shell-menu { flex: 1 1 auto; min-width: 0; overflow-x: auto }
|
||||
.shell-foot { flex: none }
|
||||
.shell-nav .brand { margin: 0 10px 0 0; font-size: 0 }
|
||||
.shell-nav .brand-icon { margin: 0; font-size: 15px }
|
||||
.nav-label { display: none }
|
||||
.nav-list { display: flex; gap: 4px }
|
||||
.nav-item { white-space: nowrap; padding: 8px 12px }
|
||||
.shell-foot { margin: 0 0 0 auto; padding: 0 0 0 14px; border-top: 0; border-left: 1px solid #2a3d35; display: flex; align-items: center }
|
||||
.shell-user strong { display: none }
|
||||
.shell-foot button { width: auto }
|
||||
.shell-foot { margin: 0 0 0 auto; padding: 0 0 0 12px; border-top: 0; border-left: 1px solid #2a3d35; display: flex; align-items: center; gap: 10px }
|
||||
.shell-account { padding: 3px; gap: 2px }
|
||||
.shell-user { padding: 3px }
|
||||
.shell-user-text { display: none }
|
||||
.shell-version { padding: 0 }
|
||||
.shell-top { padding: 22px 20px 18px }
|
||||
.shell-body { padding: 20px 20px 48px }
|
||||
}
|
||||
@@ -550,6 +609,18 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.row.depth-1, .row.depth-2, .row.depth-3 { margin-left: 14px }
|
||||
.login-card { padding: 26px }
|
||||
.login-body { padding: 16px }
|
||||
/* Zwei Rasterspalten aus Bezeichner und Wert sind auf einem Handy eine Spalte zu viel. */
|
||||
.facts { grid-template-columns: minmax(0, 1fr); gap: 2px }
|
||||
.facts dt { margin-top: 10px }
|
||||
.facts dt:first-child { margin-top: 0 }
|
||||
/* Eine sechsspaltige Tabelle passt auf kein Handy. Jede Zeile wird zu einem Block, und die
|
||||
Spaltenueberschrift steht als Bezeichner vor ihrem Wert. */
|
||||
.table-stack thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%) }
|
||||
.table-stack, .table-stack tbody, .table-stack tr, .table-stack td { display: block; width: 100% }
|
||||
.table-stack tr { border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; margin-top: 10px }
|
||||
.table-stack tbody tr + tr td { border-top: 0 }
|
||||
.table-stack td { display: flex; gap: 14px; justify-content: space-between; align-items: baseline; padding: 5px 0; text-align: right }
|
||||
.table-stack td::before { content: attr(data-label); color: var(--muted); text-align: left; flex: none }
|
||||
}
|
||||
|
||||
/* A group of related checkboxes needs a name of its own, not just a label per box. */
|
||||
@@ -557,40 +628,78 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.field-set > legend { font-size: 12px; font-weight: 700; padding: 0 6px }
|
||||
.field-set .check-label { margin-top: 14px }
|
||||
|
||||
/* ---- statistics: charts drawn by the server, styled by class ---------------------------- */
|
||||
/* ---- statistics: tiles, charts drawn by the server, ranked lists ------------------------ */
|
||||
|
||||
.range-switch { display: flex; gap: 8px; flex-wrap: wrap }
|
||||
.range-switch a { font-size: 12px; padding: 8px 14px; min-height: 36px }
|
||||
.chart { display: block; width: 100%; height: 180px; overflow: visible }
|
||||
/* Der Zeitraum als eine Leiste mit einem gedrueckten Knopf, nicht vier lose Schaltflaechen. */
|
||||
.range-switch { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper) }
|
||||
.range-switch a { display: inline-flex; align-items: center; min-height: 32px; padding: 6px 13px; border-radius: 7px; font-size: 12px; font-weight: 600; text-decoration: none; color: var(--muted) }
|
||||
.range-switch a:hover { color: var(--ink); background: #eef2ea }
|
||||
.range-switch a[aria-current=page] { background: var(--shell); color: #fff }
|
||||
/* Die Kennzahlen: eine grosse dunkle Kachel fuer die Summe, kleine helle fuer alles, was sie einordnet. */
|
||||
.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(136px, 1fr)); gap: 12px }
|
||||
.stat-tile { display: grid; align-content: start; gap: 6px; min-width: 0; padding: 16px 18px; background: var(--paper); border: 1px solid var(--line); border-radius: 12px }
|
||||
.stat-tile-main { grid-column: span 2; background: var(--shell); border-color: var(--shell); color: var(--shell-ink) }
|
||||
.stat-label { font-size: 11px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--muted) }
|
||||
.stat-value { font-size: 28px; font-weight: 650; letter-spacing: -.8px; line-height: 1.1; font-variant-numeric: tabular-nums }
|
||||
.stat-note { font-size: 12px; line-height: 1.45; color: var(--muted); overflow-wrap: anywhere }
|
||||
.stat-tile-main .stat-label, .stat-tile-main .stat-note { color: var(--shell-muted) }
|
||||
.stat-tile-main .stat-value { font-size: 40px; color: #fff }
|
||||
.stat-tile-main .stat-up { color: #bfe3a5 }
|
||||
.stat-tile-main .stat-down { color: #f3b9a8 }
|
||||
.chart { display: block; width: 100%; height: 190px; overflow: visible }
|
||||
.chart-hours, .chart-weekdays { height: 150px }
|
||||
.chart-bar { fill: var(--accent) }
|
||||
.chart-bar:hover { fill: #1c4735 }
|
||||
.chart-axis { stroke: var(--line); stroke-width: 1 }
|
||||
.chart-legend { display: flex; justify-content: space-between; margin: 8px 0 0; font-size: 11px; color: var(--muted) }
|
||||
.chart-bar-peak { fill: var(--ring) }
|
||||
.chart-grid { stroke: var(--line); stroke-width: 1; stroke-dasharray: 3 3 }
|
||||
.chart-axis { stroke: #c3cec7; stroke-width: 1 }
|
||||
.chart-legend { display: flex; justify-content: space-between; gap: 8px; margin: 8px 0 0; font-size: 11px; color: var(--muted) }
|
||||
.chart-legend-even span { flex: 1; text-align: center }
|
||||
.chart-table { margin-top: 18px }
|
||||
.chart-table > summary { cursor: pointer; font-size: 12px; font-weight: 600; padding: 6px 0 }
|
||||
.table-wrap { max-height: 320px; overflow: auto; margin-top: 10px }
|
||||
.stats-columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px }
|
||||
.stats-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 2px; counter-reset: rank }
|
||||
.stats-columns-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)) }
|
||||
/* Rang, Name, Anteil am Spitzenwert als Balken, Zahl. */
|
||||
.stats-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 2px }
|
||||
.stats-list li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr) 72px auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
border-top: 1px solid #eef1ed;
|
||||
font-size: 13px;
|
||||
}
|
||||
.stats-list li:first-child { border-top: 0 }
|
||||
.stats-list span, .stats-list a { overflow-wrap: anywhere; min-width: 0 }
|
||||
.stats-list strong { font-variant-numeric: tabular-nums; white-space: nowrap }
|
||||
.stats-rank { font-size: 11px; font-weight: 700; color: var(--muted); text-align: center }
|
||||
.stats-list span:not(.stats-rank), .stats-list a { overflow-wrap: anywhere; min-width: 0 }
|
||||
.stats-share { display: block; width: 100%; height: 6px; overflow: visible }
|
||||
.stats-share-bar { fill: var(--accent); opacity: .5 }
|
||||
.stats-list li:first-child .stats-share-bar { opacity: 1 }
|
||||
.stats-list strong { font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right }
|
||||
@media (max-width: 620px) {
|
||||
.stat-tile-main { grid-column: 1 / -1 }
|
||||
.stats-list li { grid-template-columns: 22px minmax(0, 1fr) auto }
|
||||
.stats-share { display: none }
|
||||
}
|
||||
|
||||
/* ---- system: preflight, update steps, log ---------------------------------------------- */
|
||||
|
||||
.facts { display: grid; grid-template-columns: auto 1fr; gap: 8px 20px; margin: 14px 0 0; font-size: 13px }
|
||||
.facts { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); gap: 8px 20px; margin: 14px 0 0; font-size: 13px }
|
||||
.facts dt { color: var(--muted) }
|
||||
.facts dd { margin: 0; overflow-wrap: anywhere }
|
||||
/* Die Adresse der Website auf der Systemseite: Feld und Knopf in einer Zeile, am Telefon untereinander. */
|
||||
.origin-form { margin-top: 4px; max-width: 560px }
|
||||
.origin-form label:first-of-type { margin-top: 10px }
|
||||
.origin-row { display: flex; flex-wrap: wrap; gap: 8px }
|
||||
.origin-row input { flex: 1 1 240px; width: auto; box-sizing: border-box; min-height: 40px; height: 40px }
|
||||
.card-body .origin-row .secondary { margin: 0 }
|
||||
.origin-form .check-label { margin-top: 10px }
|
||||
.link-button { border: 0; background: none; padding: 0; min-height: 0; font: inherit; color: var(--accent); text-decoration: underline; cursor: pointer }
|
||||
.check-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 10px; font-size: 13px; line-height: 1.55 }
|
||||
.check-list li { display: flex; gap: 11px; align-items: flex-start }
|
||||
.check-list li > span { min-width: 0; overflow-wrap: anywhere }
|
||||
.check-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -605,7 +714,6 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.check-bad { color: var(--danger) }
|
||||
.log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px }
|
||||
.log-list code { font-size: 11px; overflow-wrap: anywhere }
|
||||
.shell-version { margin: 0 0 4px; padding: 0 10px; font-size: 10px; color: var(--shell-muted) }
|
||||
|
||||
/* ---- the installer: six screens on an otherwise empty server ---------------------------- */
|
||||
|
||||
@@ -656,9 +764,25 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.install-details > summary { padding: 14px 0; font-size: 13px; font-weight: 600; cursor: pointer }
|
||||
.install-details[open] > summary { border-bottom: 1px solid var(--line); margin-bottom: 6px }
|
||||
|
||||
.favicon-field{display:flex;align-items:center;gap:12px}
|
||||
.favicon-field select{flex:1}
|
||||
.favicon-field{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px}
|
||||
.favicon-current{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted)}
|
||||
.favicon-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
|
||||
.favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
|
||||
.favicon-editor{display:grid;grid-template-columns:minmax(0,1fr) 220px;gap:20px;align-items:start;margin-top:14px}
|
||||
.favicon-editor[hidden]{display:none}
|
||||
.favicon-stage{position:relative;justify-self:start;max-width:100%;line-height:0;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px;user-select:none;touch-action:none}
|
||||
.favicon-stage img{display:block;max-width:100%;max-height:360px;width:auto;height:auto}
|
||||
.favicon-frame{position:absolute;box-sizing:border-box;border:2px solid #fff;outline:1px solid rgba(0,0,0,.6);box-shadow:0 0 0 9999px rgba(20,30,25,.55);cursor:move}
|
||||
.favicon-stage{overflow:hidden}
|
||||
.favicon-frame:focus-visible{outline:3px solid var(--accent);outline-offset:0}
|
||||
.favicon-handle{position:absolute;right:-7px;bottom:-7px;width:14px;height:14px;background:#fff;border:1px solid rgba(0,0,0,.6);border-radius:3px;cursor:nwse-resize}
|
||||
.favicon-side input[type=range]{width:100%}
|
||||
.favicon-samples{display:flex;align-items:flex-end;gap:16px;margin:12px 0}
|
||||
.favicon-samples figure{margin:0;text-align:center;font-size:11px;color:var(--muted)}
|
||||
.favicon-samples canvas{display:block;margin:0 auto 6px;border-radius:8px;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/12px 12px;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
|
||||
.favicon-sample-large{width:90px;height:90px}
|
||||
.favicon-sample-small{width:32px;height:32px;border-radius:4px!important}
|
||||
@media(max-width:700px){.favicon-editor{grid-template-columns:1fr}}
|
||||
|
||||
.color-field { display: flex; gap: 8px; align-items: center }
|
||||
.color-field input[type="text"], .color-field > input:not([type="color"]) { flex: 1; min-width: 0 }
|
||||
@@ -701,9 +825,8 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.nav-list { display: grid; gap: 2px }
|
||||
/* Vierundvierzig Pixel: die Flaeche, die ein Daumen zuverlaessig trifft. */
|
||||
.nav-item { min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 10px 14px; white-space: normal }
|
||||
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0; gap: 10px }
|
||||
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0 }
|
||||
.shell-version { display: none }
|
||||
.shell-user strong { display: none }
|
||||
}
|
||||
|
||||
/* Search and forms share usable widths throughout administration. */
|
||||
@@ -726,14 +849,234 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
@media(max-width:560px){.menu-add{grid-template-columns:1fr}.menu-entry.menu-child{margin-left:18px}.menu-entry-actions button{min-height:44px}.menu-slots a{flex:1;min-width:130px}}
|
||||
|
||||
/* Field definitions stay editable on narrow screens instead of stretching the entire page. */
|
||||
.form-def{min-width:0}.field-table{table-layout:fixed;width:100%}.field-table input:not([type=checkbox]),.field-table select{width:100%;min-width:0;max-width:100%}
|
||||
@media(max-width:1100px){
|
||||
.field-table,.field-table tbody{display:block}.field-table thead{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%)}
|
||||
.field-table tbody{display:grid;gap:16px}.field-table tr{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px;padding:16px;border:1px solid var(--line);border-radius:8px}
|
||||
.field-table td{display:block;border:0;padding:0;min-width:0}.field-table td::before{content:attr(data-label);display:block;font-size:12px;font-weight:600;margin-bottom:5px}.field-table .check-cell{text-align:left}
|
||||
}
|
||||
@media(max-width:560px){.field-table tr{grid-template-columns:minmax(0,1fr)}}
|
||||
.form-def{min-width:0;margin:0}
|
||||
|
||||
.version-update{display:block;width:fit-content;margin-top:8px;padding:5px 8px;border:1px solid #679576;border-radius:6px;color:#17452c;background:#e8f4e8;font-size:11px;font-weight:700;text-decoration:none}
|
||||
.version-update{display:inline-block;padding:4px 8px;border:1px solid #679576;border-radius:6px;color:#17452c;background:#e8f4e8;font-size:11px;font-weight:700;text-decoration:none}
|
||||
|
||||
.menu-entry summary::after{content:none}.menu-entry summary{list-style:none;gap:10px;padding:10px 14px}.menu-entry summary::-webkit-details-marker{display:none}.menu-drag-handle{display:grid;place-items:center;width:36px;min-width:36px;height:40px;padding:0;border:0;background:transparent;color:#576e60;cursor:grab;touch-action:none}.menu-drag-handle:active{cursor:grabbing}.menu-chevron{flex:none;color:#52665a;transition:transform .15s}.menu-entry details[open] .menu-chevron{transform:rotate(180deg)}.menu-entry-kind{font-size:11px;border:1px solid #dae4dc;border-radius:5px;padding:3px 6px;color:#506659;background:#f7f9f6;white-space:nowrap}.menu-entry-title{display:flex;align-items:center;gap:8px;min-width:0}.menu-entry-title svg{width:19px;height:19px;flex:none}.menu-dragging{opacity:.6;box-shadow:0 4px 16px #203b3426;border-color:#2c6b4f}.menu-icon-fields{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}@media(max-width:560px){.menu-entry summary{padding:8px}.menu-entry-kind{display:none}.menu-icon-fields{grid-template-columns:1fr}}
|
||||
|
||||
/* ---- the form editor: a card per field, the look, and a preview beside it -------------- */
|
||||
|
||||
.form-screens { display: grid; gap: 20px }
|
||||
.form-editor { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); gap: 20px 36px; padding-top: 2px }
|
||||
.form-editor-main { min-width: 0 }
|
||||
.form-part { margin: 26px 0 0; font-size: 11px; font-weight: 700; letter-spacing: .9px; text-transform: uppercase; color: var(--muted) }
|
||||
.form-editor-main > .form-part:first-child, .form-editor-preview .form-part, .type-preview-column .form-part { margin-top: 14px }
|
||||
.form-fields-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 28px }
|
||||
.form-fields-head .form-part { margin: 0 }
|
||||
.card-body .form-fields-head .secondary, .card-body .list-actions .secondary { margin: 0; min-height: 36px; padding: 6px 12px; font-size: 12px }
|
||||
.form-fields-head .secondary .nav-icon, .list-actions .secondary .nav-icon { width: 15px; height: 15px; color: var(--accent) }
|
||||
.field-list { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px }
|
||||
.field-card { position: relative; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: #fafbf8 }
|
||||
.field-card.is-open { padding-bottom: 14px; background: #fff; box-shadow: 0 1px 6px #203b3414 }
|
||||
.field-card-new { border-style: dashed; background: var(--paper) }
|
||||
.field-card-head { display: grid; grid-template-columns: 24px minmax(0, 1fr) 170px auto auto 34px; gap: 0 10px; align-items: center }
|
||||
.field-card-head input, .field-card-head select { min-height: 36px }
|
||||
.field-card-index { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: #e4ebe2; color: var(--accent); font-size: 12px; font-weight: 700 }
|
||||
.field-card label { margin-top: 12px }
|
||||
.field-card-head .field-card-required { margin: 0; font-size: 13px; white-space: nowrap }
|
||||
.field-toggle { display: inline-flex; align-items: center; gap: 4px; min-height: 34px; padding: 0 8px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--accent); font-size: 12px; font-weight: 650; white-space: nowrap }
|
||||
.field-toggle:hover { background: #eef3ec }
|
||||
.field-toggle .form-chevron { transition: transform .15s }
|
||||
.field-card.is-open .field-toggle .form-chevron { transform: rotate(180deg) }
|
||||
.field-card-meta { margin: 4px 0 0 34px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.field-card-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px; padding-left: 34px }
|
||||
.field-card-body[hidden] { display: none }
|
||||
.field-translation { grid-column: 1 / -1; margin-top: 12px; padding-top: 8px; border-top: 1px dashed var(--line) }
|
||||
.field-translation summary { cursor: pointer; font-size: 13px; font-weight: 650 }
|
||||
/* Ein Formular je zuklappbarer Karte, Abschnitte darin ebenfalls zuklappbar. */
|
||||
.form-card > .form-card-summary { display: flex; align-items: center; gap: 12px; padding: 16px 20px; cursor: pointer; list-style: none }
|
||||
details.form-card > summary.form-card-summary::before, details.form-card[open] > summary.form-card-summary::before { content: none }
|
||||
.form-card > .form-card-summary::-webkit-details-marker, .form-section > summary::-webkit-details-marker { display: none }
|
||||
.form-card-name { font-size: 16px; font-weight: 700 }
|
||||
.form-card > .form-card-summary { cursor: default; border-bottom: 1px solid var(--line) }
|
||||
.form-list-name { margin: 0; font-size: 15px; font-weight: 650 }
|
||||
.form-list-name a { text-decoration: none }
|
||||
.form-list-name a:hover { text-decoration: underline }
|
||||
.form-list .row { align-items: center }
|
||||
.back-to-list { display: inline-block; margin-top: 4px }
|
||||
.form-card-facts { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.form-chevron { flex: none; color: #52665a; transition: transform .15s }
|
||||
.form-card[open] > .form-card-summary { border-bottom: 1px solid var(--line) }
|
||||
.form-card[open] > .form-card-summary > .form-chevron, .form-section[open] > summary > .form-chevron { transform: rotate(180deg) }
|
||||
.form-section { border-bottom: 1px solid var(--line); padding-bottom: 12px }
|
||||
.form-section > summary { display: flex; align-items: center; gap: 10px; padding: 14px 0 2px; cursor: pointer; list-style: none }
|
||||
.form-section > summary .form-part { margin: 0 }
|
||||
.form-section-hint { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.field-card-body .field-help { margin-top: 5px }
|
||||
.field-card-flags { align-self: end; padding-bottom: 11px }
|
||||
.field-card-flags .check-label { margin: 12px 0 0 }
|
||||
.field-remove { display: grid; place-items: center; width: 38px; height: 38px; margin-bottom: 2px; padding: 0; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--muted) }
|
||||
.field-remove .nav-icon { width: 17px; height: 17px; color: inherit }
|
||||
.field-remove:hover { background: #fff0eb; border-color: #e2b2a7; color: var(--danger) }
|
||||
/* Die Vorschau bleibt beim Blaettern durch lange Feldlisten im Bild. */
|
||||
.form-editor-preview { min-width: 0; position: sticky; top: 16px; align-self: start }
|
||||
/* Die Vorschau uebernimmt Text- und Seitenfarbe der Website per Skript; die Felder lesen dieselben
|
||||
Variablen wie auf der Website selbst. */
|
||||
.form-preview { display: grid; gap: 16px; margin-top: 12px; padding: 22px; border: 1px solid var(--line); border-radius: 12px; background: #fff; font-size: 14px; line-height: 1.5 }
|
||||
.form-preview-field { display: grid; gap: 6px }
|
||||
.form-preview-label { font-weight: 600 }
|
||||
.form-preview-input { display: block; min-height: 40px; padding: 9px 12px; border: 1px solid var(--form-border, currentColor); border-radius: 8px; background: var(--form-bg, #fff); color: inherit }
|
||||
.form-preview-select { display: flex; align-items: center; justify-content: space-between }
|
||||
.form-preview-select::after { content: '⌄'; font-size: 16px; line-height: 1 }
|
||||
.form-preview-area { min-height: 92px }
|
||||
.form-preview-check { display: flex; align-items: flex-start; gap: 9px }
|
||||
.form-preview-box { flex: none; width: 18px; height: 18px; margin-top: 2px; border: 1px solid var(--form-border, currentColor); border-radius: 4px; background: var(--form-bg, #fff) }
|
||||
.form-preview-help { font-size: 12px }
|
||||
.form-preview-step { padding-top: 6px; border-top: 1px solid var(--line); display: grid; gap: 2px }
|
||||
.form-preview-step-title { font-weight: 700; font-size: 15px }
|
||||
.form-preview-radio { border-radius: 50% }
|
||||
.form-preview-number { display: flex; align-items: center; justify-content: flex-end; opacity: .7 }
|
||||
.form-preview-conditional { padding-left: 10px; border-left: 2px dashed var(--line) }
|
||||
.field-card-number { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 12px }
|
||||
.field-card-condition { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px }
|
||||
.field-card-condition .field-help { grid-column: 1 / -1 }
|
||||
.field-card-images { grid-column: 1 / -1 }
|
||||
.field-image-pickers { display: grid; gap: 6px; margin-top: 6px }
|
||||
.field-image-picker { display: grid; grid-template-columns: minmax(0, 1fr) 64px minmax(0, 1fr) auto auto; gap: 10px; align-items: center; font-size: 13px }
|
||||
.field-image-picker-thumb { width: 64px; height: 48px; object-fit: cover; border-radius: 6px; background: #eef1eb }
|
||||
.field-image-picker-thumb[hidden] { display: block; visibility: hidden }
|
||||
.field-image-picker-name { color: var(--muted); overflow-wrap: anywhere; min-width: 0 }
|
||||
@media (max-width: 767px) { .field-image-picker { grid-template-columns: 64px minmax(0, 1fr); } .field-image-picker-option { grid-column: 1 / -1; font-weight: 650 } }
|
||||
.field-condition-picker { display: grid; gap: 6px; grid-column: 1 / -1 }
|
||||
.field-condition-values { border: 0; padding: 0; margin: 4px 0 0; display: flex; flex-wrap: wrap; gap: 4px 16px }
|
||||
.field-condition-values legend { font-size: 13px; font-weight: 650; margin-bottom: 4px; padding: 0 }
|
||||
.form-preview-empty { margin: 0; font-size: 13px }
|
||||
.form-preview + .contrast-note { margin-top: 10px }
|
||||
.form-preview .cms-button { justify-self: start; pointer-events: none }
|
||||
.form-editor-foot { display: flex; align-items: center; gap: 16px; flex-wrap: wrap }
|
||||
.form-editor-foot .muted { font-size: 12px }
|
||||
.form-look .color-control { margin-top: 0 }
|
||||
@media (max-width: 1100px) { .form-editor { grid-template-columns: 1fr } }
|
||||
@media (max-width: 700px) {
|
||||
.field-card-head { grid-template-columns: 24px minmax(0, 1fr) auto; gap: 8px }
|
||||
.field-card-title { grid-column: 2 / 4; padding-right: 36px }
|
||||
.field-card-type { grid-column: 2 }
|
||||
.field-card-head .field-card-required { grid-column: 3 }
|
||||
.field-toggle { grid-column: 2 }
|
||||
.field-card-head .field-remove { position: absolute; top: 8px; right: 8px }
|
||||
.field-card-meta { margin-left: 0 }
|
||||
.field-card-body { padding-left: 0 }
|
||||
.form-card-facts { white-space: normal }
|
||||
.form-card > .form-card-summary { flex-wrap: wrap; padding: 14px 16px }
|
||||
}
|
||||
|
||||
/* ---- design: palette cards, ready-made palettes, font cards, typography preview -------- */
|
||||
|
||||
.theme-form > .card { margin: 0 }
|
||||
.palette-presets { margin-top: 14px }
|
||||
.palette-presets-label { display: block; margin-bottom: 8px; font-size: 12px; font-weight: 600 }
|
||||
.palette-preset-row { display: flex; flex-wrap: wrap; gap: 8px }
|
||||
.palette-preset { display: grid; gap: 6px; justify-items: center; padding: 8px 10px 7px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper); color: var(--ink); font-size: 11px; font-weight: 600 }
|
||||
.palette-preset svg { display: block; width: 90px; height: 24px; border-radius: 6px; overflow: hidden; box-shadow: inset 0 0 0 1px #203b3426 }
|
||||
.palette-preset:hover { border-color: var(--accent); background: #f5f8f1 }
|
||||
.palette-presets .field-help { margin-top: 10px }
|
||||
.swatch-list { list-style: none; margin: 18px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px }
|
||||
.swatch-card { position: relative; display: grid; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); overflow: hidden }
|
||||
.swatch-card-new { border-style: dashed }
|
||||
/* Der Farbwaehler ist die Flaeche selbst: nichts liegt dazwischen. */
|
||||
.swatch-well { position: relative; display: grid; margin: 0; cursor: pointer }
|
||||
.swatch-input { display: block; width: 100%; height: 76px; min-height: 0; padding: 0; border: 0; border-radius: 0; background: none; cursor: pointer }
|
||||
.swatch-input::-webkit-color-swatch-wrapper { padding: 0 }
|
||||
.swatch-input::-webkit-color-swatch { border: 0; border-radius: 0 }
|
||||
.swatch-input::-moz-color-swatch { border: 0 }
|
||||
.swatch-hex { position: absolute; left: 10px; bottom: 8px; padding: 2px 7px; border-radius: 5px; background: #ffffffe6; color: var(--ink); font-size: 11px; box-shadow: 0 1px 2px #0000001f }
|
||||
.swatch-fields { display: grid; padding: 0 12px 12px }
|
||||
.swatch-fields label { margin: 10px 0 4px; font-size: 11px }
|
||||
.swatch-fields input { min-height: 36px; padding: 6px 10px; font-size: 13px }
|
||||
.swatch-card .field-remove { position: absolute; top: 6px; right: 6px; width: 32px; height: 32px; background: #ffffffe6 }
|
||||
.list-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 16px }
|
||||
.list-actions .field-help { flex: 1 1 300px; margin: 0 }
|
||||
.font-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 12px }
|
||||
.font-card { position: relative; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); overflow: hidden }
|
||||
.font-card-new { border-style: dashed }
|
||||
/* Die Probe steht in der Schrift selbst - das Skript setzt die Familie, die Datei liegt auf dem Server. */
|
||||
.font-sample { display: flex; align-items: center; gap: 18px; min-height: 88px; padding: 18px 60px 18px 22px; background: #fafbf8; border-bottom: 1px solid var(--line) }
|
||||
.font-sample-big { flex: none; font-size: 44px; line-height: 1; font-weight: 500 }
|
||||
.font-sample-line { display: grid; gap: 6px; min-width: 0 }
|
||||
.font-sample-text { font-size: 19px; line-height: 1.3; overflow-wrap: anywhere }
|
||||
.font-sample-weights { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13px; color: var(--muted) }
|
||||
.font-weight-100 { font-weight: 100 } .font-weight-200 { font-weight: 200 } .font-weight-300 { font-weight: 300 }
|
||||
.font-weight-400 { font-weight: 400 } .font-weight-500 { font-weight: 500 } .font-weight-600 { font-weight: 600 }
|
||||
.font-weight-700 { font-weight: 700 } .font-weight-800 { font-weight: 800 } .font-weight-900 { font-weight: 900 }
|
||||
.font-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0 16px; padding: 0 22px 18px }
|
||||
.font-fields label { margin-top: 12px }
|
||||
.font-weights { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 16px 0 0; padding: 0; border: 0 }
|
||||
.font-weights legend { float: left; margin-right: 8px; padding: 0; font-size: 12px; font-weight: 600 }
|
||||
.chip { display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 5px 10px; border: 1px solid var(--line); border-radius: 20px; background: var(--paper); font-size: 12px; font-weight: 500; cursor: pointer }
|
||||
.chip input { width: 14px; min-height: 14px; margin: 0 }
|
||||
.chip:has(input:checked) { border-color: var(--accent); background: #edf4e8; color: #22553e }
|
||||
.font-card .field-remove { position: absolute; top: 10px; right: 10px }
|
||||
.type-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 380px); gap: 16px 36px }
|
||||
.type-fields { min-width: 0 }
|
||||
.type-colors .color-control { margin-top: 0 }
|
||||
.type-preview-column { min-width: 0 }
|
||||
.type-preview { margin-top: 12px; padding: 26px 26px 22px; border: 1px solid var(--line); border-radius: 12px; background: #fff; overflow: hidden }
|
||||
.type-preview p { margin: 0 }
|
||||
.type-preview .type-preview-h1 { margin-bottom: 10px; font-size: 34px; font-weight: 700; line-height: 1.15; letter-spacing: -.5px }
|
||||
.type-preview .type-preview-h2 { margin-bottom: 14px; font-size: 26px; font-weight: 600; line-height: 1.2 }
|
||||
.type-preview-p { font-size: 17px; line-height: 1.6 }
|
||||
.type-preview-link { text-decoration: underline; text-underline-offset: 3px }
|
||||
.theme-save { display: flex; align-items: center; gap: 16px; flex-wrap: wrap }
|
||||
.theme-save .muted { font-size: 12px }
|
||||
.theme-form .button-defaults { margin: 0; background: var(--paper) }
|
||||
@media (max-width: 1000px) { .type-layout { grid-template-columns: 1fr } }
|
||||
/* Ein Dokument in der Mediathek: drei Buchstaben statt eines Zeichens. */
|
||||
.row-icon-text { font-size: 11px; font-weight: 700; letter-spacing: .04em }
|
||||
/* Ein Bild der Mediathek als Formularfeld: Vorschau, Name, Wählen und Entfernen. */
|
||||
.media-field-label { display: block; font-weight: 650; font-size: 13px; margin-bottom: 6px }
|
||||
.media-field { min-width: 0 }
|
||||
.media-field-current { display: flex; align-items: center; gap: 10px; min-height: 64px; padding: 6px; border: 1px solid var(--line); border-radius: 9px; background: #fff }
|
||||
.media-field-thumb { width: 96px; height: 64px; object-fit: cover; border-radius: 6px; flex-shrink: 0; background: #eef1eb }
|
||||
.media-field-name { font-size: 13px; color: var(--muted); overflow-wrap: anywhere; min-width: 0 }
|
||||
.media-field-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px }
|
||||
/* Ein Linkfeld: die Adresse zum Tippen, darunter Seite oder PDF zum Wählen. */
|
||||
.link-field-actions { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 4px }
|
||||
.link-field-actions .text-button { padding: 4px 0; font-size: 13px }
|
||||
/* Vorhandene Kategorien und Schlagwörter zum Anklicken. */
|
||||
.term-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px }
|
||||
.term-chip { border: 1px solid var(--line); background: #fff; color: var(--ink); border-radius: 999px; padding: 4px 11px; font-size: 12px; cursor: pointer }
|
||||
.term-chip[aria-pressed="true"] { background: #edf4dc; border-color: var(--accent); font-weight: 650 }
|
||||
.term-chip[aria-pressed="true"]::before { content: '✓ '; }
|
||||
/* Der Ziehknopf eines Menüeintrags liegt über der linken Ecke der Zusammenfassung, gehört aber
|
||||
nicht zu ihr - sonst stünde ein Knopf in einem Knopf. */
|
||||
.menu-entry { position: relative }
|
||||
.menu-entry > .menu-drag-handle { position: absolute; left: 14px; top: 10px; z-index: 1 }
|
||||
.menu-entry summary { padding-left: 58px; min-height: 60px }
|
||||
@media (max-width: 560px) { .menu-entry > .menu-drag-handle { left: 8px; top: 8px } .menu-entry summary { padding-left: 50px; min-height: 56px } }
|
||||
/* Texte der Website in weiteren Sprachen (Einstellungen → Sprachen, ADR 111). */
|
||||
.language-texts { margin-top: 24px; padding-top: 16px; border-top: 1px solid #dfe5e1 }
|
||||
.language-texts h3 { margin: 0 0 6px; font-size: 15px }
|
||||
.language-text-group { display: grid; gap: 6px; border: 1px solid #dfe5e1; border-radius: 8px; padding: 12px 16px; margin: 12px 0; min-width: 0 }
|
||||
.language-text-group legend { font-weight: 600; padding: 0 4px }
|
||||
/* ---- Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft --------------------- */
|
||||
.update-wait, .update-aktuell { display: flex; align-items: center; gap: 10px; margin: 0 0 12px }
|
||||
.update-spinner { width: 16px; height: 16px; flex: none; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: update-dreh .8s linear infinite }
|
||||
@keyframes update-dreh { to { transform: rotate(360deg) } }
|
||||
@media (prefers-reduced-motion: reduce) { .update-spinner { animation-duration: 2.4s } }
|
||||
.update-neu { border: 1px solid #b9d7c2; background: #f1f8f2; border-radius: 10px; padding: 16px 18px; margin: 0 0 14px; display: grid; gap: 6px; justify-items: start }
|
||||
.update-neu h3 { margin: 0; font-size: 17px }
|
||||
.update-neu .muted { margin: 0 }
|
||||
.update-neu-label { margin: 0; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #1f6b45 }
|
||||
.update-notizen { max-height: 220px; overflow: auto; width: 100%; font-size: 13px; line-height: 1.55; color: #33413a; background: #fff; border: 1px solid #dfe5e1; border-radius: 8px; padding: 10px 12px; margin: 6px 0 10px }
|
||||
.update-lauf { background: #1f2d27; color: #e9efe9; border-radius: 12px; padding: 18px 20px; margin: 0 0 14px; display: grid; gap: 12px; justify-items: start }
|
||||
.update-lauf-kopf { display: flex; align-items: center; gap: 10px; margin: 0; font-weight: 650 }
|
||||
.update-schritte { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; width: 100% }
|
||||
.update-schritt { display: flex; align-items: center; gap: 10px; font-size: 14px }
|
||||
.update-marke { display: grid; place-items: center; width: 22px; height: 22px; flex: none; border-radius: 50%; border: 2px solid #5e6f66; font-size: 12px; font-weight: 700 }
|
||||
.update-schritt.is-erledigt .update-marke { background: #3fa46a; border-color: #3fa46a; color: #fff }
|
||||
.update-schritt.is-aktiv .update-marke { border-color: #f2c14e; box-shadow: 0 0 0 4px #f2c14e33; animation: update-puls 1.2s ease-in-out infinite }
|
||||
.update-schritt.is-offen { color: #8f9e96 }
|
||||
.update-schritt.is-fehler .update-marke { background: #d4553f; border-color: #d4553f; color: #fff }
|
||||
@keyframes update-puls { 50% { box-shadow: 0 0 0 8px #f2c14e11 } }
|
||||
@media (prefers-reduced-motion: reduce) { .update-schritt.is-aktiv .update-marke { animation: none } }
|
||||
.update-meldung { margin: 0; font-size: 13px; color: #c9d4cd }
|
||||
.update-meldung.is-ok { color: #9fdcb5 }
|
||||
.update-meldung.is-fehler { color: #ffb4a6 }
|
||||
.update-lauf .secondary { background: transparent; color: #fff; border-color: #ffffff66 }
|
||||
.update-letztes { margin: 0 0 12px }
|
||||
.update-manual { margin-top: 6px }
|
||||
.update-dialog { max-width: 460px; border: 0; border-radius: 12px; padding: 24px 26px; box-shadow: 0 18px 48px #0003 }
|
||||
.update-dialog::backdrop { background: #1f2d2766 }
|
||||
.update-dialog h2 { margin: 0 0 10px; font-size: 18px }
|
||||
.update-dialog .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px }
|
||||
@@ -1 +1 @@
|
||||
.button-designer{padding:16px 0;border-block:1px solid #dce4dc;margin-block:16px}.button-designer h3{margin:0 0 12px;font-size:15px}.button-state-previews{display:grid;gap:12px;padding:16px;background:#fff;border:1px solid #dce4dc;border-radius:12px;margin-bottom:16px}.button-state{display:grid;justify-items:start;gap:7px;min-width:0}.button-state>.field-help{margin:0}.button-preset-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0 16px}.button-preset{display:grid;justify-items:center;align-content:center;gap:9px;min-width:0;padding:12px 6px;background:#fff;border:1px solid #dce4dc;border-radius:10px;color:#203b34;cursor:pointer}.button-preset[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#f1f6ef}.button-preset:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.button-preset .cms-button{font-size:12px!important;padding:6px 10px;min-height:34px;pointer-events:none}.button-preset-label{font-size:11px;line-height:1.4;text-align:center}.button-designer select{width:100%;margin-bottom:12px}.button-defaults{margin-block:24px;padding:24px;border:1px solid #dce4dc;border-radius:14px;min-width:0}.button-defaults legend{font-size:20px;font-weight:700;padding:0 8px}.button-defaults-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr);gap:28px}.button-defaults .fields{align-content:start}.button-defaults .button-preview-column{min-width:0}.button-defaults .button-preview-column h3{font-size:15px;margin:0 0 12px}.button-defaults .color-field{margin:0}.button-defaults .color-trigger{width:100%}@media(max-width:900px){.button-defaults-layout{grid-template-columns:1fr}.button-defaults{padding:16px}}
|
||||
.button-designer{padding:16px 0;border-block:1px solid #dce4dc;margin-block:16px}.button-designer h3{margin:0 0 12px;font-size:15px}.button-state-previews{display:grid;gap:12px;padding:16px;background:#fff;border:1px solid #dce4dc;border-radius:12px;margin-bottom:16px}.button-state{display:grid;justify-items:start;gap:7px;min-width:0}.button-state>.field-help{margin:0}.button-preset-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0 16px}.button-preset{display:grid;justify-items:center;align-content:center;gap:9px;min-width:0;padding:12px 6px;background:#fff;border:1px solid #dce4dc;border-radius:10px;color:#203b34;cursor:pointer}.button-preset[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#f1f6ef}.button-preset:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.button-preset .cms-button{font-size:12px!important;padding:6px 10px;min-height:34px;pointer-events:none}.button-preset-label{font-size:11px;line-height:1.4;text-align:center}.button-designer select{width:100%;margin-bottom:12px}.button-defaults{margin-block:24px;padding:24px;border:1px solid #dce4dc;border-radius:14px;min-width:0}.button-defaults legend{font-size:20px;font-weight:700;padding:0 8px}.button-defaults-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr);gap:28px}.button-defaults .fields{align-content:start}.button-defaults .button-preview-column{min-width:0}.button-defaults .button-preview-column h3{font-size:15px;margin:0 0 12px}.button-defaults .color-field{margin:0}.button-defaults .color-trigger{width:100%}@media(max-width:900px){.button-defaults-layout{grid-template-columns:1fr}.button-defaults{padding:16px}}.icon-picker-label{margin:0 0 6px;font-size:13px;font-weight:700;color:#203b34}.icon-field{margin:16px 0 14px}.icon-field-row{display:grid;grid-template-columns:44px minmax(0,1fr) auto;align-items:center;gap:10px;padding:8px;border:1px solid #dce4dc;border-radius:10px;background:#fff}.icon-field-preview{display:grid;place-items:center;width:44px;height:44px;border-radius:8px;background:#eef5ea;color:#1d4d37}.icon-field-preview svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.icon-field-preview.is-empty{background:repeating-linear-gradient(135deg,#f3f5f2 0 6px,#fff 6px 12px);border:1px dashed #c5d2c7}.icon-field-name{font-size:14px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.icon-field-change{min-height:36px;white-space:nowrap}.icon-setting{margin:0 0 14px}.segmented{display:grid;border:1px solid #dce4dc;border-radius:9px;overflow:hidden}.segmented button{min-height:38px;border:0;background:#fff;color:#203b34;font-size:13px;font-weight:600;cursor:pointer}.segmented button+button{border-left:1px solid #dce4dc}.segmented button[aria-pressed=true]{background:#2c6b4f;color:#fff}.segmented button:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}.icon-design-link{display:block;margin:4px 0 8px;font-weight:600}dialog.icon-dialog{max-width:860px;width:calc(100% - 40px);padding:0;overflow:hidden}.icon-dialog::backdrop{background:#1b2a2466}.icon-dialog-head{display:flex;align-items:center;justify-content:space-between;padding:18px 22px;border-bottom:1px solid #dce4dc}.icon-dialog-head h2{margin:0;font-size:19px}.icon-dialog-close{display:grid;place-items:center;width:40px;height:40px;border:0;border-radius:8px;background:transparent;color:#203b34;cursor:pointer}.icon-dialog-close:hover{background:#eef3ec}.icon-dialog-close svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round}.icon-dialog-body{display:grid;grid-template-columns:200px minmax(0,1fr);height:min(520px,calc(100vh - 220px))}.icon-dialog-tabs{display:flex;flex-direction:column;gap:2px;padding:12px;border-right:1px solid #dce4dc;background:#f7faf5;overflow:auto}.icon-dialog-tab{text-align:left;padding:10px 12px;border:0;border-radius:8px;background:transparent;color:#203b34;font-size:14px;font-weight:600;cursor:pointer}.icon-dialog-tab:hover{background:#eaf1e6}.icon-dialog-tab[aria-selected=true]{background:#2c6b4f;color:#fff}.icon-dialog-tab:focus-visible,.icon-choice:focus-visible,.icon-dialog-close:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.icon-dialog-panel{padding:16px;overflow:auto}.icon-dialog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:8px}.icon-choice{display:grid;justify-items:center;align-content:center;gap:8px;min-height:92px;padding:12px 6px;border:1px solid #dce4dc;border-radius:10px;background:#fff;color:#203b34;cursor:pointer;transition:border-color .15s,background-color .15s}.icon-choice svg{width:28px;height:28px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}.icon-choice-name{font-size:12px;line-height:1.3;text-align:center}.icon-choice:hover{border-color:#8fb39f;background:#f7faf5}.icon-choice[aria-pLine truncated
|
||||
@@ -35,7 +35,7 @@ const TOOLS = [
|
||||
{ action: 'delete', glyph: '✕', title: label => `${label} löschen`, danger: true },
|
||||
];
|
||||
|
||||
export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSelected, onSelect, onAction, onInsert, onDrop }) {
|
||||
export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSelected, onSelect, onAction, onInsert, onDrop, onResize = () => {} }) {
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'canvas-overlay';
|
||||
const hover = element('div', 'ov-box ov-hover');
|
||||
@@ -205,6 +205,7 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
|
||||
// The toolbar is placed last because it has to keep clear of the upper plus sign, and only
|
||||
// now is it settled whether there is one.
|
||||
positionToolbar(selectedBox, Boolean(sibling));
|
||||
placeResizers();
|
||||
const hoverBox = hovered && hovered !== selectedId ? rectOf(hovered) : null;
|
||||
if (place(hover, hoverBox)) paintLabel(hoverLabel, hovered);
|
||||
if (dragging) {
|
||||
@@ -213,6 +214,124 @@ export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSe
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Column widths. Umut: "am besten per Drag and Drop im Editor".
|
||||
//
|
||||
// The border between two columns of the selected row is a handle. Dragging it moves share from
|
||||
// one neighbour to the other; the row is restyled inline while the pointer moves, and the
|
||||
// result is written to the document once, when the pointer is released. The arrow keys do the
|
||||
// same in steps of five, so the widths are reachable without a mouse.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
const resizers = [];
|
||||
let focusResizer = null;
|
||||
|
||||
/** The row whose borders can be dragged: the selected row, or the row around the selected column. */
|
||||
function resizableRow() {
|
||||
const item = getSelected() ? locate(getDocument().blocks, getSelected()) : null;
|
||||
if (!item) return null;
|
||||
if (item.block.type === 'row') return item.block;
|
||||
return item.parent?.type === 'row' ? item.parent : null;
|
||||
}
|
||||
|
||||
/** The measured widths of the columns as shares of their sum - the truth a drag starts from. */
|
||||
function sharesOf(rowId) {
|
||||
const kids = childElements(rowId);
|
||||
const total = kids.reduce((sum, kid) => sum + kid.box.width, 0);
|
||||
return total > 0 ? kids.map(kid => kid.box.width / total * 100) : null;
|
||||
}
|
||||
|
||||
/** Moves `delta` points of share from the right neighbour to the left one; neither may vanish. */
|
||||
function shift(shares, index, delta) {
|
||||
const pair = shares[index] + shares[index + 1];
|
||||
const least = Math.min(10, pair / 2);
|
||||
const left = Math.max(least, Math.min(pair - least, shares[index] + delta));
|
||||
const next = shares.slice();
|
||||
next[index] = left;
|
||||
next[index + 1] = pair - left;
|
||||
return next;
|
||||
}
|
||||
|
||||
const tenth = share => Math.round(share * 10) / 10;
|
||||
const formatShares = shares => shares.map(share => String(tenth(share))).join(' ');
|
||||
const tracksOf = shares => shares.map(share => `minmax(0,${tenth(share)}fr)`).join(' ');
|
||||
|
||||
function placeResizers() {
|
||||
const row = dragging ? null : resizableRow();
|
||||
const kids = row ? childElements(row.id) : [];
|
||||
// Stacked columns - a phone, or a single-column layout - have no border to drag.
|
||||
const sideBySide = kids.length > 1 && kids.every((kid, index) => index === 0 || kid.box.left >= kids[index - 1].box.right - 1);
|
||||
const wanted = sideBySide ? kids.length - 1 : 0;
|
||||
while (resizers.length > wanted) resizers.pop().remove();
|
||||
while (resizers.length < wanted) resizers.push(resizeButton());
|
||||
const width = 14 / getScale();
|
||||
resizers.forEach((button, index) => {
|
||||
const left = kids[index].box, right = kids[index + 1].box;
|
||||
button.dataset.row = row.id;
|
||||
button.dataset.index = String(index);
|
||||
const label = `Breite zwischen Spalte ${index + 1} und ${index + 2} ändern`;
|
||||
button.setAttribute('aria-label', label);
|
||||
button.title = `${label}: ziehen oder Pfeiltasten`;
|
||||
button.style.left = `${(left.right + right.left) / 2 - width / 2}px`;
|
||||
button.style.top = `${Math.min(left.top, right.top)}px`;
|
||||
button.style.width = `${width}px`;
|
||||
button.style.height = `${Math.max(left.bottom, right.bottom) - Math.min(left.top, right.top)}px`;
|
||||
if (focusResizer && focusResizer.row === row.id && focusResizer.index === index) { button.focus(); focusResizer = null; }
|
||||
});
|
||||
}
|
||||
|
||||
function resizeButton() {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'ov-resize';
|
||||
button.addEventListener('pointerdown', startResize);
|
||||
button.addEventListener('keydown', event => {
|
||||
const step = event.key === 'ArrowLeft' ? -5 : event.key === 'ArrowRight' ? 5 : 0;
|
||||
if (!step) return;
|
||||
event.preventDefault();
|
||||
const rowId = button.dataset.row, index = Number(button.dataset.index);
|
||||
const shares = sharesOf(rowId);
|
||||
if (!shares) return;
|
||||
focusResizer = { row: rowId, index };
|
||||
onResize(rowId, formatShares(shift(shares, index, step)));
|
||||
});
|
||||
layer.append(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
function startResize(event) {
|
||||
if (event.button !== 0) return;
|
||||
const button = event.currentTarget;
|
||||
const rowId = button.dataset.row, index = Number(button.dataset.index);
|
||||
const start = sharesOf(rowId);
|
||||
const node = doc()?.getElementById(rowId);
|
||||
if (!start || !node) return;
|
||||
event.preventDefault();
|
||||
button.setPointerCapture(event.pointerId);
|
||||
layer.classList.add('is-resizing');
|
||||
// Pointer pixels are editor pixels; the shares live in the frame's scale, on the summed
|
||||
// width of the columns without the gaps between them.
|
||||
const content = childElements(rowId).reduce((sum, kid) => sum + kid.box.width, 0);
|
||||
const startX = event.clientX;
|
||||
let current = start;
|
||||
const move = moved => {
|
||||
current = shift(start, index, (moved.clientX - startX) / getScale() / content * 100);
|
||||
node.style.gridTemplateColumns = tracksOf(current);
|
||||
sync();
|
||||
};
|
||||
const finish = () => {
|
||||
button.removeEventListener('pointermove', move);
|
||||
button.removeEventListener('pointerup', finish);
|
||||
button.removeEventListener('pointercancel', finish);
|
||||
layer.classList.remove('is-resizing');
|
||||
if (formatShares(current) !== formatShares(start)) onResize(rowId, formatShares(current));
|
||||
else sync();
|
||||
};
|
||||
button.addEventListener('pointermove', move);
|
||||
button.addEventListener('pointerup', finish);
|
||||
button.addEventListener('pointercancel', finish);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Dropping.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
@@ -8,7 +8,7 @@ const node = (tag, className, text) => {
|
||||
};
|
||||
const hexPattern = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i;
|
||||
|
||||
export function createColorControl({ id, label, value = '', inheritedValue = '', colors, onChange, onComplete }) {
|
||||
export function createColorControl({ id, label, value = '', inheritedValue = '', colors, onChange, onComplete = () => {} }) {
|
||||
const wrap = node('div', 'color-control');
|
||||
const trigger = node('button', 'color-trigger');
|
||||
trigger.type = 'button'; trigger.id = id;
|
||||
|
||||
@@ -18,7 +18,9 @@ export const GROUPS = {
|
||||
image: 'Bild',
|
||||
layout: 'Layout',
|
||||
effect: 'Wirkung',
|
||||
menu: 'Menü-Knopf',
|
||||
slider: 'Slider-Steuerung',
|
||||
icon: 'Symbol',
|
||||
};
|
||||
|
||||
// dvh before vh: on a phone 100vh is taller than the screen, 100dvh is what people mean.
|
||||
@@ -247,6 +249,23 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
return wrap;
|
||||
};
|
||||
|
||||
/**
|
||||
* The column shares of a row, "2 1" for two thirds and a third. Dragging the border between two
|
||||
* columns in the preview writes the same value; the field is for anyone who prefers numbers.
|
||||
*/
|
||||
const fractionsControl = (id, value, apply) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = id;
|
||||
input.value = value ?? '';
|
||||
input.placeholder = 'gleich breit';
|
||||
input.pattern = String.raw`\d{1,3}(\.\d)?( \d{1,3}(\.\d)?){0,11}`;
|
||||
input.setAttribute('inputmode', 'decimal');
|
||||
input.title = 'Anteile je Spalte, durch Leerzeichen getrennt: 2 1 oder 25 50 25';
|
||||
input.addEventListener('change', () => { if (input.checkValidity()) apply(input.value.trim()); });
|
||||
return input;
|
||||
};
|
||||
|
||||
const numberControl = (id, value, property, apply) => {
|
||||
const wrap = element('div', 'number-control');
|
||||
const slider = document.createElement('input');
|
||||
@@ -502,9 +521,9 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
if (properties.length === 0) continue;
|
||||
const details = element('details', 'design-group');
|
||||
const groupKey = `${block.type}:${group}`;
|
||||
details.open = openGroups.get(groupKey) ?? (group === priority[0] || group === 'spacing');
|
||||
details.open = openGroups.get(groupKey) ?? (group === priority[0] || group === 'spacing' || group === 'icon');
|
||||
renderedGroups.set(groupKey, details);
|
||||
details.append(element('summary', null, groupLabel));
|
||||
details.append(element('summary', null, groupLabel)); details.dataset.group = group;
|
||||
const body = element('div', 'design-group-body');
|
||||
|
||||
for (const [key, property] of properties) {
|
||||
@@ -530,6 +549,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText);
|
||||
else if (property.type === 'font') control = fontControl(id, own ? current.value : '', apply, inheritedText);
|
||||
else if (property.type === 'media') control = mediaControl(key, current.value || '', apply);
|
||||
else if (property.type === 'fractions') control = fractionsControl(id, own ? current.value : '', apply);
|
||||
else continue;
|
||||
|
||||
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset, chips: deviceChips(block, key, property.label, false) }));
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"iconcard",
|
||||
"stat",
|
||||
"quote",
|
||||
"cta"
|
||||
"cta",
|
||||
"button"
|
||||
],
|
||||
"values": {
|
||||
"left": [
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
.kant .d-tablet-radius-small{border-radius:8px}
|
||||
.kant .d-tablet-radius-large{border-radius:24px}
|
||||
}
|
||||
@media(max-width:650px){
|
||||
@media(max-width:767px){
|
||||
.kant .d-mobile-align-left{text-align:left;margin-inline:0}
|
||||
.kant .d-mobile-align-center{text-align:center;margin-inline:auto}
|
||||
.kant .d-mobile-align-right{text-align:right;margin-inline:auto 0}
|
||||
|
||||
+148
-24
@@ -1,5 +1,5 @@
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider' , legal: 'Rechtszeile'};
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄' };
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile', html: 'Eigenes HTML', language: 'Sprachwahl' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '</>', language: '文' };
|
||||
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
|
||||
// document, stay selectable and movable, but the editor never edits or fills them.
|
||||
/**
|
||||
@@ -10,9 +10,9 @@ export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H',
|
||||
export const CATEGORIES = [
|
||||
{ name: 'Aufbau', hint: 'Sektion → Zeile → Spalte. Der Inhalt kommt in die Spalte.', types: ['section', 'row', 'column'] },
|
||||
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'button', 'icon', 'divider', 'spacer'] },
|
||||
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'logos', 'progress'] },
|
||||
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'social', 'logos', 'progress'] },
|
||||
{ name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] },
|
||||
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'form', 'legal', 'partialref', 'embed'] },
|
||||
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'language', 'form', 'legal', 'partialref', 'embed', 'html'] },
|
||||
];
|
||||
|
||||
/** How many columns a row can be added with. */
|
||||
@@ -30,10 +30,35 @@ export function whyNot(type) {
|
||||
}
|
||||
|
||||
export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/;
|
||||
/**
|
||||
* Die Bausteine der aktiven Plugins, vom Server mit dem Dokument geliefert. Sie bekommen
|
||||
* Beschriftung, Symbol, Vorgaben und eine eigene Gruppe im Werkzeugkasten; was das Dokument
|
||||
* enthält, aber hier nicht steht, bleibt ein Baustein einer fehlenden Erweiterung.
|
||||
*/
|
||||
export const PLUGIN_BLOCKS = {};
|
||||
/** Die Seitenvorlagen der aktiven Plugins: id => {label, blocks}. */
|
||||
export const PLUGIN_PRESETS = {};
|
||||
export function registerPlugins(list, presets = []) {
|
||||
for (const key of Object.keys(PLUGIN_BLOCKS)) { delete PLUGIN_BLOCKS[key]; delete labels[key]; delete icons[key]; }
|
||||
for (const key of Object.keys(PLUGIN_PRESETS)) delete PLUGIN_PRESETS[key];
|
||||
for (const preset of presets || []) if (typeof preset?.id === 'string' && Array.isArray(preset.blocks)) PLUGIN_PRESETS[preset.id] = preset;
|
||||
const types = [];
|
||||
for (const definition of list || []) {
|
||||
if (!PLUGIN_TYPE.test(definition?.type || '')) continue;
|
||||
PLUGIN_BLOCKS[definition.type] = definition;
|
||||
labels[definition.type] = definition.label;
|
||||
icons[definition.type] = definition.icon || '⬡';
|
||||
types.push(definition.type);
|
||||
}
|
||||
const group = CATEGORIES.find(category => category.name === 'Erweiterungen');
|
||||
if (group) group.types = types;
|
||||
else if (types.length) CATEGORIES.push({ name: 'Erweiterungen', hint: 'Bausteine der eingeschalteten Plugins.', types });
|
||||
}
|
||||
export const pluginDefinition = block => PLUGIN_BLOCKS[block?.type] ?? null;
|
||||
export const isPluginBlock = block => typeof block?.type === 'string' && PLUGIN_TYPE.test(block.type);
|
||||
export const blockLabel = block => labels[block?.type] ?? (isPluginBlock(block) ? `Baustein ${block.type}` : 'Unbekannt');
|
||||
export const blockIcon = block => icons[block?.type] ?? '❔';
|
||||
export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'].includes(block?.type);
|
||||
export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'].includes(block?.type) || PLUGIN_BLOCKS[block?.type]?.container === true;
|
||||
// Nothing may be dropped into a plugin block: the core does not know its layout contract.
|
||||
// Containers that accept only one kind of child, mirrored by the server side validator.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -55,8 +80,12 @@ export const isContainer = block => ['section', 'row', 'column', 'gallery', 'log
|
||||
|
||||
/** Containers that take one kind of child and nothing else. */
|
||||
export const ALLOW = { row: ['column'], gallery: ['image'], logos: ['image'], accordion: ['accordionitem'], tabs: ['tabitem'] };
|
||||
/** Blocks that exist only inside one of these parents. */
|
||||
export const REQUIRE = { column: ['row'], accordionitem: ['accordion'], tabitem: ['tabs'] };
|
||||
/**
|
||||
* Blocks that exist only inside one of these parents. A column belongs to a row - and, as a
|
||||
* single one, directly to a section: that is how a button or a short text gets centred and
|
||||
* narrowed without a one-column row around it. Umut asked for exactly that.
|
||||
*/
|
||||
export const REQUIRE = { column: ['row', 'section'], accordionitem: ['accordion'], tabitem: ['tabs'] };
|
||||
/**
|
||||
* A row is not refused by any particular parent - a tab panel or an accordion entry may well
|
||||
* hold one - but it is not a thing a document starts with. At the top level a page begins with
|
||||
@@ -64,8 +93,10 @@ export const REQUIRE = { column: ['row'], accordionitem: ['accordion'], tabitem:
|
||||
*/
|
||||
export const ROOT_NEEDS_CONTAINER = ['row'];
|
||||
|
||||
export const canContain = (parent, block) => isContainer(parent) && !isPluginBlock(parent)
|
||||
&& (ALLOW[parent.type] === undefined || ALLOW[parent.type].includes(block?.type))
|
||||
// Ein Plugin-Container nimmt auf, was sein Manifest erlaubt; ein Plugin ohne Definition nichts.
|
||||
const allowedIn = parent => PLUGIN_BLOCKS[parent?.type] ? (PLUGIN_BLOCKS[parent.type].children ?? undefined) : ALLOW[parent?.type];
|
||||
export const canContain = (parent, block) => isContainer(parent) && (!isPluginBlock(parent) || PLUGIN_BLOCKS[parent.type]?.container === true)
|
||||
&& (allowedIn(parent) === undefined || allowedIn(parent).includes(block?.type))
|
||||
&& (block?.type === undefined || REQUIRE[block.type] === undefined || REQUIRE[block.type].includes(parent.type));
|
||||
export const clone = value => structuredClone(value);
|
||||
|
||||
@@ -82,6 +113,11 @@ export function locate(blocks, id, parent = null) {
|
||||
export function createBlock(type) {
|
||||
if (!Object.hasOwn(labels, type)) throw new Error('Unbekannter Block.');
|
||||
const block = { id: `b_${crypto.randomUUID().replaceAll('-', '')}`, type };
|
||||
if (PLUGIN_BLOCKS[type]) {
|
||||
block.props = Object.fromEntries(Object.entries(PLUGIN_BLOCKS[type].props || {}).map(([key, schema]) => [key, schema.default ?? '']));
|
||||
if (PLUGIN_BLOCKS[type].container) block.children = [];
|
||||
return block;
|
||||
}
|
||||
if (isContainer(block)) block.children = [];
|
||||
if (type === 'accordion') block.children = [createBlock('accordionitem')];
|
||||
// A single tab is not a tab group, and the script needs at least two to enhance anything.
|
||||
@@ -92,6 +128,10 @@ export function createBlock(type) {
|
||||
if (type === 'postlist') block.props = { count: 3, category: '', tag: '', layout: 'karten' };
|
||||
if (type === 'postnav') block.props = { previousLabel: 'Vorheriger Beitrag', nextLabel: 'Nächster Beitrag' };
|
||||
if (type === 'legal') block.props = { label: 'Rechtliches' };
|
||||
// Welche Sprachen es gibt, weiß die Website; der Block sagt nur, ob er Kürzel oder Namen zeigt.
|
||||
if (type === 'language') block.props = { display: 'code' };
|
||||
// Leer eingefuegt: nichts steht auf der Seite, bis jemand Code eintraegt.
|
||||
if (type === 'html') block.props = { html: '' };
|
||||
if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' };
|
||||
if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' };
|
||||
if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 };
|
||||
@@ -107,24 +147,67 @@ export function createBlock(type) {
|
||||
if (type === 'embed') block.props = { group: 'external', title: '', provider: '', url: '', hint: '', height: 400 };
|
||||
if (type === 'icon') block.props = { icon: 'star', size: 48, decorative: true, label: '', href: '' };
|
||||
if (type === 'progress') block.props = { label: 'Fortschritt', value: 70, showValue: true };
|
||||
// Welche Profile es gibt, steht in den Einstellungen; der Block bringt nur die Darstellung mit.
|
||||
if (type === 'social') block.props = { size: 24, shape: 'plain', layout: 'horizontal', labels: false, networks: [], newTab: true };
|
||||
if (type === 'slider') block.props = { desktopVisible: 3, tabletVisible: 2, mobileVisible: 1, snap: true, dots: true, autoplay: 0 };
|
||||
if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 };
|
||||
return block;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hebt ein geladenes Dokument auf die Fassung, die seine Bausteine und Eigenschaften verlangen.
|
||||
*
|
||||
* Normalerweise tut das der Editor in dem Moment, in dem jemand etwas einstellt. Ein Dokument,
|
||||
* das ein Skript geschrieben hat, kann aber eine Eigenschaft tragen, die seine Fassung noch nicht
|
||||
* kennt - der Kopfbereich von kant-it.de hatte die Hamburger-Umschaltung bei Fassung 19. Die
|
||||
* Website zeigt so etwas, der Server lehnt Vorschau und Speichern aber ab, und der Bereich war im
|
||||
* Editor nicht mehr zu bearbeiten. Nur Fassungen, die etwas hinzufügen, werden hier gehoben; die
|
||||
* Umstellung auf formatierten Text (12, 17) ändert die Bedeutung und bleibt eine bewusste Wahl.
|
||||
*/
|
||||
export function repairSchema(document) {
|
||||
const walk = blocks => {
|
||||
for (const block of blocks || []) {
|
||||
upgradeForType(document, block.type);
|
||||
const props = block.props || {};
|
||||
if ('caption' in props) upgradeDocument(document, 3);
|
||||
if ('click' in props || 'lightbox' in props) upgradeDocument(document, 19);
|
||||
if ('newTab' in props) upgradeDocument(document, 20);
|
||||
if (Object.values(block.style || {}).some(values => values?.backgroundImage === 'none')) upgradeDocument(document, 20);
|
||||
if (block.anchor) upgradeDocument(document, 21);
|
||||
if ('collapse' in props) upgradeDocument(document, 22);
|
||||
if ('faq' in props) upgradeDocument(document, 23);
|
||||
if (block.type === 'button' && ('icon' in props || 'iconPosition' in props)) upgradeDocument(document, 25);
|
||||
if (block.type === 'button' && ('iconMotion' in props || 'iconOnly' in props)) upgradeDocument(document, 26);
|
||||
walk(block.children);
|
||||
}
|
||||
};
|
||||
walk(document.blocks);
|
||||
if (document.translations) upgradeDocument(document, 27);
|
||||
return document;
|
||||
}
|
||||
|
||||
/** Raises the document schema to the version that knows this block type. */
|
||||
export function upgradeForType(document, type) {
|
||||
if (type === 'gallery') upgradeDocument(document, 3);
|
||||
if (type === 'postlist') upgradeDocument(document, 5);
|
||||
if (PLUGIN_BLOCKS[type]) upgradeDocument(document, 4);
|
||||
// Die Fassung, die der Server fuer die Eigenschaften verlangt, mit denen createBlock den
|
||||
// Baustein anlegt - nicht die, in der der Typ zuerst auftauchte. Sonst steht ein neuer
|
||||
// Baustein sofort mit 422 in der Vorschau und laesst sich nicht speichern.
|
||||
if (type === 'postlist') upgradeDocument(document, 18);
|
||||
if (type === 'postnav') upgradeDocument(document, 18);
|
||||
if (type === 'legal') upgradeDocument(document, 19);
|
||||
if (type === 'html') upgradeDocument(document, 24);
|
||||
if (type === 'language') upgradeDocument(document, 27);
|
||||
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
|
||||
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
|
||||
if (type === 'form') upgradeDocument(document, 8);
|
||||
if (type === 'breadcrumb') upgradeDocument(document, 9);
|
||||
if (type === 'menu' || type === 'partialref') upgradeDocument(document, 11);
|
||||
if (type === 'menu') upgradeDocument(document, 16);
|
||||
if (type === 'partialref') upgradeDocument(document, 11);
|
||||
if (type === 'embed') upgradeDocument(document, 14);
|
||||
if (['icon', 'progress', 'slider'].includes(type)) upgradeDocument(document, 15);
|
||||
if (type === 'icon' || type === 'progress') upgradeDocument(document, 15);
|
||||
if (type === 'slider') upgradeDocument(document, 17);
|
||||
if (type === 'social') upgradeDocument(document, 23);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,7 +269,9 @@ export function moveBlock(document, selected, targetId, inside = false) {
|
||||
if (!source || !target || selected === targetId || locate(source.block.children || [], targetId)) return false;
|
||||
if (inside && !isContainer(target.block)) return false;
|
||||
if (inside && !canContain(target.block, source.block)) return false;
|
||||
if (!inside && target.parent && !canContain(target.parent, source.block)) return false;
|
||||
// Neben ein Element der obersten Ebene darf nur, was dort auch stehen darf - eine Spalte oder
|
||||
// ein Akkordeoneintrag nicht.
|
||||
if (!inside && !dropList(document, target.parent?.id ?? null, source.block)) return false;
|
||||
source.siblings.splice(source.index, 1);
|
||||
const destination = locate(document.blocks, targetId);
|
||||
if (inside) (destination.block.children ??= []).push(source.block);
|
||||
@@ -217,10 +302,18 @@ function place(document, block, parentId, index) {
|
||||
return block.id;
|
||||
}
|
||||
|
||||
/** Inserts a new block at an exact position instead of behind the selection. */
|
||||
/**
|
||||
* Inserts a new block at an exact position instead of behind the selection.
|
||||
*
|
||||
* Erst pruefen, ob das Ziel den Baustein nimmt, dann die Fassung anheben: sonst bliebe bei einer
|
||||
* Ablehnung nur die Schemaaenderung uebrig - ein Verlaufsschritt, ein "ungespeichert", und nichts
|
||||
* zu sehen.
|
||||
*/
|
||||
export function insertBlockAt(document, type, parentId, index) {
|
||||
const block = createBlock(type);
|
||||
if (!dropList(document, parentId, block)) return null;
|
||||
upgradeForType(document, type);
|
||||
return place(document, createBlock(type), parentId, index);
|
||||
return place(document, block, parentId, index);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -255,7 +348,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
|
||||
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
||||
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
||||
// nirgends. Eine Zahl, an einer Stelle.
|
||||
export const SCHEMA_VERSION = 22;
|
||||
export const SCHEMA_VERSION = 27;
|
||||
|
||||
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
|
||||
|
||||
@@ -326,12 +419,19 @@ export function mediaProps(media) {
|
||||
|
||||
export function insertMedia(document, selected, items) {
|
||||
const target = locate(document.blocks, selected)?.block;
|
||||
upgradeDocument(document, 3);
|
||||
if (target?.type === 'image') { target.props = { ...target.props, ...mediaProps(items[0]), caption: target.props.caption || '' }; return target.id; }
|
||||
if (target?.type === 'image') {
|
||||
upgradeDocument(document, 3);
|
||||
target.props = { ...target.props, ...mediaProps(items[0]), caption: target.props.caption || '' };
|
||||
return target.id;
|
||||
}
|
||||
let parent = target;
|
||||
if (target?.type !== 'gallery') {
|
||||
const id = insertBlock(document, 'gallery', selected); parent = locate(document.blocks, id).block;
|
||||
// Ein Akkordeon nimmt nur Eintraege: dann gibt es keine Galerie und nichts zu tun.
|
||||
const id = insertBlock(document, 'gallery', selected);
|
||||
if (id === null) return null;
|
||||
parent = locate(document.blocks, id).block;
|
||||
}
|
||||
upgradeDocument(document, 3);
|
||||
for (const item of items) { const image = createBlock('image'); image.props = mediaProps(item); (parent.children ??= []).push(image); }
|
||||
return parent.id;
|
||||
}
|
||||
@@ -392,21 +492,34 @@ function buildRow(columns) {
|
||||
|
||||
/** A row at an exact position, for a marked insertion point or a drop in the preview. */
|
||||
export function insertRowAt(document, columns, parentId, index) {
|
||||
const row = buildRow(columns);
|
||||
if (!dropList(document, parentId, row)) return null;
|
||||
upgradeDocument(document);
|
||||
return place(document, buildRow(columns), parentId, index);
|
||||
return place(document, row, parentId, index);
|
||||
}
|
||||
|
||||
export function insertRow(document, columns, selected) {
|
||||
const row = buildRow(columns);
|
||||
upgradeDocument(document);
|
||||
// Same placement rule as a single element, so the row does not land somewhere else than the
|
||||
// toolbox promised.
|
||||
const target = insertionTarget(document, selected, 'row');
|
||||
return target ? place(document, row, target.parentId, target.index) : null;
|
||||
if (!target) return null;
|
||||
upgradeDocument(document);
|
||||
return place(document, buildRow(columns), target.parentId, target.index);
|
||||
}
|
||||
|
||||
/** Frische Kennungen für eine Vorlage aus einem Plugin - jedes Einfügen ist ein eigener Baustein. */
|
||||
const freshIds = blocks => blocks.map(block => ({ ...structuredClone(block), id: `b_${crypto.randomUUID().replaceAll('-', '')}`, ...(Array.isArray(block.children) ? { children: freshIds(block.children) } : {}) }));
|
||||
|
||||
export function insertPreset(document, name, selected) {
|
||||
if (name === 'hero') return insertHero(document, selected);
|
||||
if (PLUGIN_PRESETS[name]) {
|
||||
const blocks = freshIds(PLUGIN_PRESETS[name].blocks);
|
||||
const top = blockPath(document.blocks, selected)[0];
|
||||
const index = top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length;
|
||||
document.blocks.splice(index, 0, ...blocks);
|
||||
upgradeDocument(document, SCHEMA_VERSION);
|
||||
return blocks[0].id;
|
||||
}
|
||||
if (!['intro', 'columns', 'cta'].includes(name)) throw new Error('Unbekannte Vorlage.');
|
||||
const section = createBlock('section');
|
||||
const heading = text => { const block = createBlock('heading'); block.props.text = text; return block; };
|
||||
@@ -440,6 +553,14 @@ export class EditorHistory {
|
||||
this.mergeKey = null;
|
||||
this.saved = JSON.stringify(document);
|
||||
}
|
||||
/** Uebernimmt die vom Server bereinigte Fassung als aktuellen Stand, der Verlauf bleibt. */
|
||||
adopt(document) {
|
||||
this.past.push(this.document);
|
||||
if (this.past.length > 100) this.past.shift();
|
||||
this.document = clone(document);
|
||||
this.future = [];
|
||||
this.mergeKey = null;
|
||||
}
|
||||
change(mutator, mergeKey = null) {
|
||||
const next = clone(this.document);
|
||||
const result = mutator(next);
|
||||
@@ -568,8 +689,11 @@ export function relevantProperty(block, key, property) {
|
||||
if (Object.values(block.style ?? {}).some(values => values[key] !== undefined || values.hover?.[key] !== undefined || values.scrolled?.[key] !== undefined)) return true;
|
||||
const container = isContainer(block);
|
||||
if (property.group === 'slider') return block.type === 'slider';
|
||||
if (property.group === 'menu') return block.type === 'menu';
|
||||
if (property.group === 'icon') return block.type === 'button' && block.props?.icon !== 'none';
|
||||
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
|
||||
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
|
||||
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact'].includes(block.type);
|
||||
if (key === 'columnWidths') return block.type === 'row';
|
||||
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact', 'social'].includes(block.type);
|
||||
return true;
|
||||
}
|
||||
+61
-34
@@ -3,36 +3,14 @@
|
||||
|
||||
The layout follows the Flatsome UX Builder: a dark bar on top, one sidebar on the left that
|
||||
shows one thing at a time, and the page itself filling the rest. */
|
||||
/* Die Farbe fuer "hier steht ein eigener Wert". Dunkler als der Fokusring, damit weisse
|
||||
Schrift darauf die Kontrastschwelle sicher nimmt (5,6:1). */
|
||||
:root{--used:#9d4e0d}
|
||||
dialog{max-width:450px;width:calc(100% - 40px);padding:28px;background:var(--paper);border:1px solid var(--line);border-radius:12px;color:var(--ink);box-shadow:0 20px 80px #1b2a2433}
|
||||
dialog::backdrop{background:#172e2966}
|
||||
dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.visually-hidden{position:absolute!important;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
||||
|
||||
/* ---- media library dialog (unchanged) ---------------------------------------------------- */
|
||||
.media-launch{margin-top:14px;white-space:normal}
|
||||
.media-dialog{width:calc(100% - 32px);max-width:1100px;max-height:calc(100dvh - 32px);overflow:auto}
|
||||
.media-heading{display:flex;justify-content:space-between;gap:20px;align-items:center}
|
||||
.media-heading h2{margin:0}
|
||||
.media-heading .eyebrow{margin-bottom:8px}
|
||||
.media-tools{display:grid;grid-template-columns:1fr 1fr;gap:24px}
|
||||
.media-tools label{margin-top:10px}
|
||||
.media-layout{display:grid;grid-template-columns:minmax(0,1fr) 270px;gap:24px}
|
||||
.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:12px;align-content:start;max-height:430px;overflow:auto;padding:4px}
|
||||
.media-card{border:2px solid var(--line);border-radius:9px;padding:7px;background:white;color:var(--ink);display:flex;flex-direction:column;gap:7px;text-align:left;min-width:0}
|
||||
.media-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
|
||||
.media-card img{width:100%;height:115px;object-fit:cover;border-radius:5px}
|
||||
.media-card strong{font-size:12px;overflow-wrap:anywhere}
|
||||
.media-card span{font-size:11px;color:var(--muted)}
|
||||
.media-details{padding-left:22px;border-left:1px solid var(--line)}
|
||||
.media-details button,.media-details a{margin-top:12px;display:block}
|
||||
.media-details input{font-size:13px}
|
||||
.media-footer{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:15px}
|
||||
.media-footer label{margin:0;font-size:12px}
|
||||
#media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
|
||||
#media-status{font-size:12px;margin-block:15px}
|
||||
.media-dialog> .panel-hint{margin-top:20px}
|
||||
.media-details>p{font-size:12px}
|
||||
@media(max-width:650px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
|
||||
|
||||
/* ---- shell ------------------------------------------------------------------------------- */
|
||||
html:has(>.editor-body){overflow:hidden;height:100%}
|
||||
@@ -185,8 +163,10 @@ html:has(>.editor-body){overflow:hidden;height:100%}
|
||||
.design-row{margin-top:14px}
|
||||
.design-row-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||
.design-row-head label{margin:0;font-size:11px;line-height:1.5;flex:1;min-width:0}
|
||||
.design-own .design-row-head label{color:#2f5d4a;font-weight:700}
|
||||
.design-own .design-row-head label::before{content:'● ';color:var(--accent)}
|
||||
/* Belegte Zeilen tragen dieselbe Farbe wie die belegten Geraetechips - eine Farbe, eine
|
||||
Bedeutung: hier steht ein eigener Wert und nicht der geerbte. */
|
||||
.design-own .design-row-head label{color:var(--used);font-weight:700}
|
||||
.design-own .design-row-head label::before{content:'● ';color:var(--used)}
|
||||
.design-reset-one{font-size:14px;padding:2px 6px;min-height:24px;line-height:1}
|
||||
.design-inherited{font-size:10px;color:var(--muted);margin:5px 0 0;line-height:1.45}
|
||||
.length-control{display:grid;grid-template-columns:minmax(0,1fr) 74px;gap:6px}
|
||||
@@ -210,12 +190,21 @@ html:has(>.editor-body){overflow:hidden;height:100%}
|
||||
.side-unit{display:flex;align-items:center;gap:7px;margin-top:7px}
|
||||
.side-unit label{margin:0;font-size:10px;color:var(--muted)}
|
||||
.side-unit-select{flex:1;min-width:0}
|
||||
/* The device chips on a single option: which devices carry an own value, and switch to one. */
|
||||
.device-chips{display:flex;gap:2px;margin-left:auto}
|
||||
.device-chip{width:19px;height:19px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:4px;background:#fff;color:var(--muted);font:700 9px/1 system-ui,sans-serif;cursor:pointer}
|
||||
.device-chip:hover{border-color:var(--accent)}
|
||||
.device-chip-own{background:#eef5e6;border-color:var(--accent);color:var(--accent)}
|
||||
.device-chip[aria-pressed=true]{outline:2px solid var(--ring,#b55b12);outline-offset:1px}
|
||||
/* The device chips on a single option: which devices carry an own value, and switch to one.
|
||||
|
||||
Zwei Zustaende, zwei Mittel. "Belegt" - dieses Geraet traegt einen eigenen Wert - ist die
|
||||
Auskunft, die man ueber die ganze Liste hinweg sucht, und bekommt deshalb die kraeftige Farbe.
|
||||
"Gerade bearbeitet" ist ein Rahmen, und zwar ein Rahmen INNEN: ein `outline` liegt ausserhalb
|
||||
des Kaestchens, laeuft bei 2 px Abstand unter den Nachbarn und wird von der seitlich
|
||||
beschnittenen Spalte gekappt - es sah aus wie ein abgeschnittener roter Kasten. */
|
||||
.device-chips{display:flex;flex:none;gap:6px;margin-left:auto}
|
||||
.device-chip{width:22px;height:22px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:5px;background:#fff;color:var(--muted);font:700 10px/1 system-ui,sans-serif;cursor:pointer}
|
||||
.device-chip:hover{border-color:var(--ink)}
|
||||
.device-chip-own{background:var(--used);border-color:var(--used);color:#fff}
|
||||
.device-chip[aria-pressed=true]{border-width:2px;border-color:var(--ink)}
|
||||
.device-chip-own[aria-pressed=true]{border-color:var(--ink);box-shadow:inset 0 0 0 1px #fff}
|
||||
/* Der Fokusring bleibt innerhalb der Luecke zwischen zwei Chips - nichts liegt uebereinander. */
|
||||
.device-chip:focus-visible{outline-width:2px;outline-offset:0}
|
||||
.design-side-head{margin-bottom:5px}
|
||||
.design-side-head .design-group-label{margin:0}
|
||||
/* A number field that can be dragged sideways. The cursor is the only announcement it needs. */
|
||||
@@ -277,8 +266,14 @@ html:has(>.editor-body){overflow:hidden;height:100%}
|
||||
.ov-drop{position:absolute;background:var(--ring,#b55b12);border-radius:2px;box-shadow:0 0 0 1px #fff8;pointer-events:none}
|
||||
.ov-drop-inside{background:color-mix(in srgb, var(--ring,#b55b12) 15%, transparent);border:2px dashed var(--ring,#b55b12);box-shadow:none}
|
||||
.ov-drop-label{position:absolute;pointer-events:none;background:#2c634a;color:#fff;border-radius:5px;padding:6px 9px;font:600 12px/1.4 system-ui,sans-serif;white-space:nowrap;box-shadow:0 2px 7px #0002}
|
||||
/* The border between two columns of the selected row: a grab area with a short bar in it. */
|
||||
.ov-resize{position:absolute;display:grid;place-items:center;padding:0;min-height:0;border:0;background:transparent;cursor:col-resize;pointer-events:auto;touch-action:none}
|
||||
.ov-resize::before{content:"";width:4px;height:40px;max-height:60%;border-radius:2px;background:var(--ring,#b55b12);box-shadow:0 0 0 1px #fff8;opacity:.55;transition:opacity .12s}
|
||||
.ov-resize:hover::before,.ov-resize:focus-visible::before,.canvas-overlay.is-resizing .ov-resize::before{opacity:1}
|
||||
.ov-resize:focus-visible{outline:2px solid var(--ring,#b55b12);outline-offset:-2px}
|
||||
.canvas-overlay.is-resizing .ov-hover,.canvas-overlay.is-resizing .ov-toolbar,.canvas-overlay.is-resizing .ov-insert{display:none}
|
||||
/* While something is being dragged the outlines would only add noise. */
|
||||
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert{display:none}
|
||||
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert,.canvas-overlay.is-dragging .ov-resize{display:none}
|
||||
@media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}}
|
||||
|
||||
/* ---- narrow screens ---------------------------------------------------------------------- */
|
||||
@@ -290,3 +285,35 @@ html:has(>.editor-body){overflow:hidden;height:100%}
|
||||
.canvas-panel{height:62vh;flex:none}.sidebar{height:62vh;flex:none;border-right:0;border-top:1px solid var(--line)}
|
||||
.library-buttons{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
}
|
||||
/* Übersetzen (ADR 111). Der Umschalter sitzt neben der Vorschaugröße; im Übersetzungsmodus
|
||||
verschwindet alles, was den Aufbau ändert - Reiter, Blockaktionen, Werkzeuge der Vorschau. */
|
||||
.language-switch{display:flex;gap:2px;padding:3px;background:#ffffff14;border-radius:9px}
|
||||
.language-switch[hidden]{display:none}
|
||||
.language-switch button{border:0;background:transparent;color:#c9d4cd;padding:0 10px;min-height:30px;min-width:36px;border-radius:6px;font-size:12px;font-weight:700;letter-spacing:.04em}
|
||||
.language-switch button:hover:not(:disabled){color:#fff}
|
||||
.editor-header .language-switch button[aria-pressed=true]{background:#fff;color:var(--ink)}
|
||||
#editor[data-language] .language-switch button[aria-pressed=true]{background:#f5c451;color:#1d1d1b}
|
||||
#editor[data-language] .sidebar-nav,#editor[data-language] .sidebar-foot,#editor[data-language] .ov-toolbar,#editor[data-language] .ov-insert,#editor[data-language] .ov-resize,#editor[data-language] .ov-drop{display:none!important}
|
||||
.translate-head{display:grid;gap:8px;margin-bottom:16px}
|
||||
.translate-head h2{margin:0;font-size:16px}
|
||||
.translate-head h2:focus{outline:none}
|
||||
.translate-head progress{width:100%;height:8px;accent-color:var(--ink)}
|
||||
.translate-summary{margin:0;font-size:13px;font-weight:600}
|
||||
.translate-group{border-top:1px solid var(--line,#dfe5e1);padding:12px 0 4px;scroll-margin-top:12px}
|
||||
.translate-group.is-current{box-shadow:inset 3px 0 0 #f5c451;padding-left:10px}
|
||||
.translate-group h3{margin:0 0 8px;font-size:13px}
|
||||
.translate-jump{border:0;background:none;padding:0;font:inherit;font-weight:700;color:inherit;text-align:left;cursor:pointer}
|
||||
.translate-jump:hover{text-decoration:underline}
|
||||
.translate-field{display:grid;gap:5px;margin-bottom:14px}
|
||||
.translate-field-head{display:flex;justify-content:space-between;align-items:baseline;gap:8px}
|
||||
.translate-field-head label{margin:0}
|
||||
.translate-state{font-size:11px;font-weight:700;padding:2px 7px;border-radius:999px;background:#e8ece9;color:#384640;white-space:nowrap}
|
||||
.translate-field[data-state=missing] .translate-state{background:#fde9c8;color:#6b3f00}
|
||||
.translate-field[data-state=stale] .translate-state{background:#fbd5d0;color:#7a1d12}
|
||||
.translate-field[data-state=done] .translate-state{background:#d7eedf;color:#1c5233}
|
||||
.translate-original{margin:0;padding:6px 8px;border-radius:6px;background:#f2f4f3;color:#46514c;font-size:12.5px;line-height:1.45;white-space:pre-line;max-height:7.5em;overflow:auto}
|
||||
.translate-actions{display:flex;gap:12px;flex-wrap:wrap}
|
||||
.translate-slug{display:flex;align-items:center;gap:4px}
|
||||
.translate-slug input{flex:1;min-width:0}
|
||||
.translate-prefix{font-size:12px;color:#5b6862;white-space:nowrap}
|
||||
.translate-filter{margin-top:4px}
|
||||
+505
-44
@@ -1,5 +1,6 @@
|
||||
import { EditorHistory, labels, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, insertionTarget, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, insertRowAt, setEffect, insertMedia, upgradeDocument, CATEGORIES, ROW_COLUMNS, whyNot, dropList } from './editor-model.js';
|
||||
import { openMedia, createMediaPreview } from './media.js';
|
||||
import { openMedia, openDocuments, createMediaPreview } from './media.js';
|
||||
import { openPageDialog } from './page-picker.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { renderButtonControls, setButtonDesign } from './button-design.js';
|
||||
import { createRichText } from './rich-text.js';
|
||||
@@ -8,11 +9,14 @@ import { preview, rowPreview } from './library-previews.js';
|
||||
import { decorateContainers } from './preview-containers.js';
|
||||
import { initMotion } from './motion.js';
|
||||
import { initSliders } from './slider.js';
|
||||
import { VISIBILITY, setVisibility } from './editor-model.js';
|
||||
import { VISIBILITY, setVisibility, registerPlugins, pluginDefinition, PLUGIN_PRESETS, repairSchema } from './editor-model.js';
|
||||
|
||||
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
|
||||
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check'];
|
||||
// Symbole, ihre deutschen Namen und die Kachelauswahl. Die Liste spiegelt DocumentValidator::ICONS.
|
||||
import { renderIconField } from './icons.js';
|
||||
import { entries as translationEntries, setText, confirmText, setPageValue, slugify, plainText } from './translate.js';
|
||||
const ICON_SIZES = [12, 16, 20, 24, 32, 40, 48, 56, 64, 80, 96, 120, 160, 200, 240];
|
||||
// Mirrors SocialProfiles::NETWORKS; the addresses themselves live in the settings.
|
||||
const SOCIAL_NETWORKS = { facebook: 'Facebook', instagram: 'Instagram', linkedin: 'LinkedIn', youtube: 'YouTube', tiktok: 'TikTok', x: 'X', threads: 'Threads', pinterest: 'Pinterest', xing: 'Xing', whatsapp: 'WhatsApp', google: 'Google-Unternehmensprofil' };
|
||||
|
||||
const root = document.querySelector('#editor');
|
||||
const $ = selector => document.querySelector(selector);
|
||||
@@ -29,6 +33,9 @@ let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, d
|
||||
// same factor, so it is kept here instead of being recomputed from the transform string.
|
||||
let previewScale = 1, overlay = null, pendingInsert = null;
|
||||
const collapsed = new Set();
|
||||
// Übersetzen (ADR 111): welche Sprachen es gibt, und welche gerade bearbeitet wird. null ist die
|
||||
// Hauptsprache - dann ist der Editor genau der von vorher.
|
||||
let languageInfo = null, language = null, translateFilter = 'all';
|
||||
let renderedProperties = null;
|
||||
let revealAfterPreview = null;
|
||||
let previewPlayback = false, stopPlayback = () => {};
|
||||
@@ -116,21 +123,31 @@ function controls() {
|
||||
$('#undo').disabled = !ready || busy || !history.past.length;
|
||||
$('#redo').disabled = !ready || busy || !history.future.length;
|
||||
const current = ready ? locate(history.document.blocks, selected) : null;
|
||||
for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent', 'go-parent', 'add-here', 'close-element']) $(`#${id}`).disabled = !current || busy;
|
||||
for (const id of ['duplicate', 'delete', 'move-up', 'move-down', 'nest', 'outdent', 'go-parent', 'add-here', 'close-element']) $(`#${id}`).disabled = !current || busy || Boolean(language);
|
||||
if (current) {
|
||||
$('#move-up').disabled ||= current.index === 0;
|
||||
$('#move-down').disabled ||= current.index === current.siblings.length - 1;
|
||||
$('#nest').disabled ||= current.index === 0 || !canContain(current.siblings[current.index - 1], current.block);
|
||||
$('#outdent').disabled ||= !current.parent;
|
||||
// Ausruecken heisst: neben den Elternblock. Was dort nicht stehen darf (eine Spalte, ein
|
||||
// Akkordeoneintrag), bleibt, wo es ist.
|
||||
$('#outdent').disabled ||= !current.parent || !dropList(history.document, locate(history.document.blocks, current.parent.id)?.parent?.id ?? null, current.block);
|
||||
$('#go-parent').disabled ||= !current.parent;
|
||||
}
|
||||
updateLibrary();
|
||||
$('#presets').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => input.disabled = busy);
|
||||
// Waehrend des Speicherns ist alles gesperrt - und danach nur das wieder frei, was diese
|
||||
// Sperre geschlossen hat. Das Design-Bedienfeld schaltet Knoepfe gezielt ab ("Gestaltung
|
||||
// einfuegen" ohne Zwischenablage), und die muessen abgeschaltet bleiben.
|
||||
$('#properties').querySelectorAll('input,textarea,select,button').forEach(input => {
|
||||
if (busy) { if (!input.disabled) { input.disabled = true; input.dataset.busyLock = '1'; } }
|
||||
else if (input.dataset.busyLock) { input.disabled = false; delete input.dataset.busyLock; }
|
||||
});
|
||||
document.querySelectorAll('[data-panel],[data-viewport],.sidebar-nav [role=tab],#tree-search,#library-search,#expand-all,#collapse-all').forEach(input => input.disabled = !ready || busy);
|
||||
$('[data-panel="design"]').disabled ||= !current;
|
||||
$('#open-media-library').disabled = !ready || busy;
|
||||
$('#preview-motion').disabled = !ready || busy;
|
||||
$('#language-switch').querySelectorAll('button').forEach(button => button.disabled = !ready || busy);
|
||||
$('#translate-panel').querySelectorAll('input,textarea,button').forEach(input => { input.disabled = busy; });
|
||||
}
|
||||
|
||||
function renderTree() {
|
||||
@@ -192,6 +209,7 @@ function choose(id, scroll = true) {
|
||||
history.mergeKey = null;
|
||||
syncView();
|
||||
renderTree(); renderProperties(); controls(); highlight(scroll);
|
||||
if (language && selected) focusTranslation(selected);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,6 +217,7 @@ function choose(id, scroll = true) {
|
||||
* the exception: there the selection only moves the highlight, editing is one more step.
|
||||
*/
|
||||
function syncView() {
|
||||
if (language) return;
|
||||
if (selected && view !== 'structure') setView('element');
|
||||
else if (!selected && view === 'element') setView('elements');
|
||||
}
|
||||
@@ -233,7 +252,7 @@ function renderProperties() {
|
||||
const button = document.createElement('button'); button.type = 'button'; button.className = 'crumb'; button.textContent = item.type ? blockLabel(item) : 'Seite';
|
||||
button.addEventListener('click', () => choose(item.id)); $('#breadcrumbs').append(button);
|
||||
}
|
||||
if (block && isPluginBlock(block)) {
|
||||
if (block && isPluginBlock(block) && !pluginDefinition(block)) {
|
||||
// The core has no schema for a plugin block, so it offers structure actions only and never
|
||||
// touches its stored properties.
|
||||
const note = document.createElement('p'); note.className = 'panel-note';
|
||||
@@ -279,7 +298,7 @@ function renderContent(block, panel) {
|
||||
for (const [val, text] of Object.entries(options.choices)) { const option = document.createElement('option'); option.value = val; option.textContent = text; input.append(option); }
|
||||
} else input = document.createElement(options.multiline ? 'textarea' : 'input');
|
||||
input.id = id; input.value = value ?? '';
|
||||
if (options.number && !options.choices) { input.type = 'number'; input.min = options.min || '1'; input.max = options.max || '8000'; }
|
||||
if (options.number && !options.choices) { input.type = 'number'; input.min = options.min ?? '1'; input.max = options.max ?? '8000'; }
|
||||
if (options.checkbox) { input.type = 'checkbox'; input.checked = Boolean(value); labelEl.className = 'check-label'; labelEl.prepend(input); }
|
||||
if (options.maxLength) input.maxLength = options.maxLength;
|
||||
let previousChoice = input.value;
|
||||
@@ -307,6 +326,8 @@ function renderContent(block, panel) {
|
||||
if (key === 'click' || key === 'lightbox') upgradeDocument(doc, 19);
|
||||
if (key === 'newTab') upgradeDocument(doc, 20);
|
||||
if (key === 'collapse') upgradeDocument(doc, 22);
|
||||
if (key === 'faq') upgradeDocument(doc, 23);
|
||||
if (key === 'iconOnly') upgradeDocument(doc, 26);
|
||||
if (current.type === 'image' && key === 'src') { delete current.props.sources; delete current.props.mediaId; }
|
||||
}, options.choices ? null : `${selected || 'page'}:${key}`);
|
||||
if (key === 'src') imagePreview?.update({ src: input.value });
|
||||
@@ -319,13 +340,74 @@ function renderContent(block, panel) {
|
||||
input.addEventListener('blur', () => { history.mergeKey = null; });
|
||||
if (options.checkbox) container.append(labelEl); else container.append(labelEl, input);
|
||||
if (options.help) { const help = document.createElement('p'); help.className = 'field-help'; help.id = `${id}-help`; help.textContent = options.help; input.setAttribute('aria-describedby', help.id); container.append(help); }
|
||||
if (key === 'href') field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||
if (key === 'href' || options.link) {
|
||||
// Ein PDF ist ein Linkziel wie jedes andere: seine Adresse steht danach im Feld. Ohne neuen
|
||||
// Tab zeigt die Website es im Betrachter über der Seite.
|
||||
// Eine eigene Seite sucht man, statt ihren Pfad zu kennen.
|
||||
const choose = value => { input.value = value; applyValue(); history.mergeKey = null; };
|
||||
const pages = document.createElement('button'); pages.type = 'button'; pages.className = 'secondary href-document'; pages.textContent = 'Seite wählen';
|
||||
pages.addEventListener('click', () => openPageDialog({ onChoose: item => choose(item.path === 'home' ? '/' : `/${item.path}`) }));
|
||||
const pdf = document.createElement('button'); pdf.type = 'button'; pdf.className = 'secondary href-document'; pdf.textContent = 'PDF aus Mediathek wählen';
|
||||
pdf.addEventListener('click', () => openDocuments({ onChoose: item => choose(item.src) }));
|
||||
const row = document.createElement('div'); row.className = 'href-pickers'; row.append(pages, pdf);
|
||||
container.append(row);
|
||||
// Den Schalter kennen nur die Linkbausteine des Kerns; ein Plugin legt seine Felder selbst fest.
|
||||
if (key === 'href' && !options.plugin) field('In neuem Tab öffnen', 'newTab', block.props.newTab === true, { checkbox: true, container });
|
||||
}
|
||||
};
|
||||
// Eine Eigenschaft setzen, die kein Textfeld ist (Symbol, Stellung, Bewegung). Danach wird das
|
||||
// Panel neu gezeichnet, und der Fokus kehrt zu dem Bedienelement zurück, das sie gesetzt hat.
|
||||
const choose = (key, next, version, focus) => {
|
||||
history.change(doc => {
|
||||
const current = locate(doc.blocks, selected)?.block;
|
||||
if (!current) return;
|
||||
(current.props ??= {})[key] = next;
|
||||
// Ohne Symbol gibt es keinen Button nur aus Symbol - sonst stünde ein leerer Knopf da.
|
||||
if (key === 'icon' && next === 'none') delete current.props.iconOnly;
|
||||
if (version) upgradeDocument(doc, version);
|
||||
}, null);
|
||||
renderProperties(); renderTree(); controls(); schedulePreview();
|
||||
document.querySelector(focus)?.focus({ preventScroll: true });
|
||||
};
|
||||
const iconField = (label, value, options = {}) => panel.append(renderIconField({
|
||||
label, value, none: options.none, onChoose: name => choose('icon', name, options.version, '.icon-field-change'),
|
||||
}));
|
||||
// Zwei bis drei Möglichkeiten nebeneinander statt einer Auswahlliste.
|
||||
const segmented = (label, key, current, options, version) => {
|
||||
const wrap = document.createElement('div'); wrap.className = 'icon-setting';
|
||||
const caption = document.createElement('p'); caption.className = 'icon-picker-label'; caption.id = `segmented-${key}`; caption.textContent = label;
|
||||
const group = document.createElement('div'); group.className = 'segmented'; group.setAttribute('role', 'group'); group.setAttribute('aria-labelledby', caption.id);
|
||||
group.style.gridTemplateColumns = `repeat(${options.length}, 1fr)`;
|
||||
for (const [value, text] of options) {
|
||||
const option = document.createElement('button'); option.type = 'button'; option.dataset[key] = value; option.textContent = text;
|
||||
option.setAttribute('aria-pressed', String(value === current));
|
||||
option.addEventListener('click', () => { if (value !== current) choose(key, value, version, `[data-${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${value}"]`); });
|
||||
group.append(option);
|
||||
}
|
||||
wrap.append(caption, group); panel.append(wrap);
|
||||
};
|
||||
if (!block) {
|
||||
field('Seitentitel', 'title', history.document.title, { maxLength: 160 });
|
||||
field('Beschreibung für Suchmaschinen', 'description', history.document.description, { multiline: true, maxLength: 320 });
|
||||
return;
|
||||
}
|
||||
const plugin = pluginDefinition(block);
|
||||
if (plugin) { renderPluginFields(block, panel, plugin, field); return; }
|
||||
/*
|
||||
* Eine Auswahl statt einer abgetippten Adresse, für alles, was ein Baustein nur verknüpft.
|
||||
* Eine gespeicherte Adresse, die es nicht mehr gibt, bleibt sichtbar statt still zu
|
||||
* verschwinden. Ohne die Liste (sie kommt mit dem Design-Schema) bleibt es ein Textfeld.
|
||||
*/
|
||||
const pick = (kind, current, empty = null) => {
|
||||
const list = designSchema?.choices?.[kind];
|
||||
if (!list) return {};
|
||||
const choices = {};
|
||||
if (empty !== null) choices[''] = empty;
|
||||
else if (!current) choices[''] = 'Bitte wählen …';
|
||||
Object.assign(choices, list);
|
||||
if (current && !(current in list)) choices[current] = `${current} (nicht mehr vorhanden)`;
|
||||
return { choices };
|
||||
};
|
||||
if (block.type === 'text') {
|
||||
// Formatted text with a toolbar and an HTML view. The document moves to v12 on the first
|
||||
// formatted save; until then it keeps plain text semantics and stays escaped when rendered.
|
||||
@@ -336,13 +418,33 @@ function renderContent(block, panel) {
|
||||
if (current) current.props.text = markup;
|
||||
}, `rich-${block.id}`);
|
||||
controls(); schedulePreview();
|
||||
}, { label: 'Text' });
|
||||
}, { label: 'Text', plain: history.document.schemaVersion < 12 });
|
||||
const label = document.createElement('label'); label.textContent = 'Text';
|
||||
const help = document.createElement('p'); help.className = 'field-help';
|
||||
help.textContent = 'Fett, kursiv, Listen und Links. Über HTML lässt sich die Auszeichnung direkt bearbeiten. Der Server lässt nur eine feste Liste an Auszeichnungen durch; alles andere wird beim Speichern entfernt, der Text bleibt.';
|
||||
panel.append(label, rich.element, help);
|
||||
}
|
||||
if (['heading', 'button'].includes(block.type)) field(block.type === 'button' ? 'Beschriftung' : 'Text', 'text', block.props.text, { multiline: block.type !== 'button' });
|
||||
if (block.type === 'button') {
|
||||
// Ohne eigene Wahl trägt ein Button den Pfeil, den er vor v25 immer hatte.
|
||||
const icon = block.props.icon ?? 'arrow-up-right';
|
||||
iconField('Symbol', icon, { none: 'Kein Symbol', version: 25 });
|
||||
if (icon !== 'none') {
|
||||
segmented('Stellung', 'iconPosition', block.props.iconPosition === 'start' ? 'start' : 'end', [['start', 'Vor dem Text'], ['end', 'Nach dem Text']], 25);
|
||||
segmented('Beim Überfahren', 'iconMotion', block.props.iconMotion ?? 'none', [['none', 'Ruhig'], ['slide', 'Rückt'], ['reveal', 'Erscheint']], 26);
|
||||
field('Nur das Symbol zeigen', 'iconOnly', block.props.iconOnly === true, { checkbox: true, help: 'Ein runder oder eckiger Knopf nur mit Symbol. Die Beschriftung bleibt als Name für Vorlesesoftware erhalten – bitte trotzdem sinnvoll ausfüllen.' });
|
||||
// Farbe, Fläche, Größe und Drehung sind Gestaltung: sie gelten je Gerät und beim Überfahren,
|
||||
// wie alle anderen Werte unter Design. Ein Knopf führt direkt dorthin.
|
||||
const design = document.createElement('button'); design.type = 'button'; design.className = 'text-button icon-design-link';
|
||||
design.textContent = 'Farbe, Größe und Fläche des Symbols → Design';
|
||||
design.addEventListener('click', () => {
|
||||
document.querySelector('[data-panel="design"]')?.click();
|
||||
const group = document.querySelector('.design-group[data-group="icon"]');
|
||||
if (group) { group.open = true; group.scrollIntoView({ block: 'start' }); group.querySelector('summary')?.focus({ preventScroll: true }); }
|
||||
});
|
||||
panel.append(design);
|
||||
}
|
||||
}
|
||||
if (block.type === 'button' && designSchema) buttonControls(block, panel);
|
||||
if (INLINE_TEXT[block.type]) {
|
||||
const inline = document.createElement('button');
|
||||
@@ -357,10 +459,10 @@ function renderContent(block, panel) {
|
||||
}
|
||||
if (block.type === 'heading') field('Überschriftenebene', 'level', block.props.level, { number: true, choices: { 1: 'H1 · Hauptüberschrift', 2: 'H2 · Abschnitt', 3: 'H3 · Unterabschnitt', 4: 'H4', 5: 'H5', 6: 'H6' }, help: 'Jede Seite benötigt genau eine H1.' });
|
||||
if (block.type === 'iconcard') {
|
||||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
||||
iconField('Symbol', block.props.icon);
|
||||
field('Titel', 'title', block.props.title, { maxLength: 120 });
|
||||
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse.' });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
|
||||
}
|
||||
if (block.type === 'stat') {
|
||||
field('Zahl', 'value', block.props.value, { maxLength: 16, help: 'Zum Beispiel 15+, 24/7 oder 100 %.' });
|
||||
@@ -376,7 +478,7 @@ function renderContent(block, panel) {
|
||||
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
||||
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
||||
field('Beschriftung der Schaltfläche', 'buttonText', block.props.buttonText, { maxLength: 80 });
|
||||
field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||
field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
|
||||
}
|
||||
if (block.type === 'contact') {
|
||||
field('Telefon', 'phone', block.props.phone || '', { maxLength: 80, help: 'Wird als anrufbarer Link ausgegeben.' });
|
||||
@@ -387,24 +489,52 @@ function renderContent(block, panel) {
|
||||
if (block.type === 'menu') {
|
||||
field('Menüplatz', 'slot', block.props.slot, { choices: { header: 'Hauptnavigation', secondary: 'Zweite Navigation', footer: 'Fußzeile', legal: 'Rechtliches' }, help: 'Welche Einträge gezeigt werden. Gepflegt werden sie unter Menüs.' });
|
||||
field('Menüart', 'layout', block.props.layout || 'horizontal', { choices: { horizontal: 'Waagerecht', vertical: 'Untereinander', burger: 'Aufklappbar (Burger)' }, help: 'Aufklappbar funktioniert ohne JavaScript. Untermenüs klappen in allen Arten per Klick auf, nicht beim Überfahren – das ist auf einem Touchgerät die einzige Art, die funktioniert.' });
|
||||
field('Menü-Knopf', 'burgerLabel', block.props.burgerLabel || 'both', { choices: { both: 'Symbol und Text', text: 'Nur Text', icon: 'Nur Symbol' }, help: 'Wie der aufklappbare Knopf beschriftet ist. „Nur Symbol“ behält den Namen für Vorlesesoftware. Farben, Rahmen und Schriftgröße des Knopfes stehen unter Design → Menü-Knopf.' });
|
||||
field('Automatisch als Hamburger', 'collapse', block.props.collapse || 'tablet', { choices: { tablet: 'Tablet und Mobil (Standard)', mobile: 'Nur Mobil', never: 'Nie' }, help: 'Waagerechte Menüs wechseln standardmäßig bis 1024 px und auf Touchgeräten ohne Maus, auch großen iPads. „Nur Mobil“ gilt bis 767 px. Die Menüarten „Untereinander“ und „Burger“ bleiben unverändert.' });
|
||||
}
|
||||
if (block.type === 'partialref') field('Adresse der Sektion', 'partial', block.props.partial, { help: 'Die Adresse aus der Bereichsverwaltung. Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden.' });
|
||||
if (block.type === 'partialref') field('Sektion', 'partial', block.props.partial, { ...pick('sections', block.props.partial), help: 'Die Sektion wird verknüpft, nicht kopiert: eine Änderung dort wirkt auf allen Seiten, die sie einbinden. Angelegt und bearbeitet wird sie unter „Bereiche“.' });
|
||||
if (block.type === 'icon') {
|
||||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
||||
iconField('Symbol', block.props.icon);
|
||||
// number, so the value is stored as a number: the server wants an int, not "48".
|
||||
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' });
|
||||
field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' });
|
||||
if (!block.props.decorative) field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse.' });
|
||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
|
||||
}
|
||||
if (block.type === 'social') {
|
||||
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Die Adressen stehen unter Einstellungen → Unternehmensdaten → Soziale Profile. Farbe, Abstand und Ausrichtung unter Design.' });
|
||||
field('Form', 'shape', block.props.shape, { choices: { plain: 'Nur Symbol', circle: 'Runde Fläche', square: 'Eckige Fläche' } });
|
||||
field('Anordnung', 'layout', block.props.layout, { choices: { horizontal: 'Nebeneinander', vertical: 'Untereinander' } });
|
||||
field('Namen anzeigen', 'labels', block.props.labels === true, { checkbox: true, help: 'Ohne Häkchen tragen die Links ihren Namen nur für Screenreader.' });
|
||||
field('In neuem Tab öffnen', 'newTab', block.props.newTab !== false, { checkbox: true });
|
||||
// Eine Auswahl aus den eingetragenen Profilen; keins gewählt heißt alle.
|
||||
const chosen = new Set(block.props.networks ?? []);
|
||||
const group = document.createElement('fieldset'); group.className = 'field-set';
|
||||
const legend = document.createElement('legend'); legend.textContent = 'Nur diese Netzwerke'; group.append(legend);
|
||||
for (const [key, name] of Object.entries(SOCIAL_NETWORKS)) {
|
||||
const label = document.createElement('label'); label.className = 'check-label';
|
||||
const box = document.createElement('input'); box.type = 'checkbox'; box.checked = chosen.has(key);
|
||||
box.id = `social-${block.id}-${key}`; label.htmlFor = box.id;
|
||||
box.addEventListener('change', () => {
|
||||
history.change(doc => {
|
||||
const current = locate(doc.blocks, block.id)?.block; if (!current) return;
|
||||
const set = new Set(current.props.networks ?? []); box.checked ? set.add(key) : set.delete(key);
|
||||
current.props.networks = Object.keys(SOCIAL_NETWORKS).filter(network => set.has(network));
|
||||
});
|
||||
renderTree(); controls(); schedulePreview();
|
||||
});
|
||||
label.append(box, ` ${name}`); group.append(label);
|
||||
}
|
||||
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Ohne Auswahl erscheinen alle eingetragenen Profile.';
|
||||
group.append(note); panel.append(group);
|
||||
}
|
||||
if (block.type === 'progress') {
|
||||
field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
|
||||
field('Wert (%)', 'value', block.props.value, { number: true, help: 'Zwischen 0 und 100. Der Wert steht auch im Markup, nicht nur in der Balkenbreite.' });
|
||||
field('Wert (%)', 'value', block.props.value, { number: true, min: 0, max: 100, help: 'Zwischen 0 und 100. Der Wert steht auch im Markup, nicht nur in der Balkenbreite.' });
|
||||
field('Wert anzeigen', 'showValue', block.props.showValue, { checkbox: true });
|
||||
}
|
||||
if (block.type === 'slider') {
|
||||
field('Automatisch weiterblättern (Sekunden, 0 = aus)', 'autoplay', block.props.autoplay || 0, { number: true, help: 'Ab 3 Sekunden. Lädt ein kleines Skript, nur auf Seiten mit einem solchen Slider. Bei „weniger Bewegung“ im Betriebssystem bleibt er stehen, und die erste Berührung stoppt ihn endgültig.' });
|
||||
field('Automatisch weiterblättern (Sekunden, 0 = aus)', 'autoplay', block.props.autoplay || 0, { number: true, min: 0, help: 'Ab 3 Sekunden. Lädt ein kleines Skript, nur auf Seiten mit einem solchen Slider. Bei „weniger Bewegung“ im Betriebssystem bleibt er stehen, und die erste Berührung stoppt ihn endgültig.' });
|
||||
field('Punkte und Pfeile', 'dots', block.props.dots === true, { checkbox: true, help: 'Zeichnet der Browser selbst, ohne Skript. Wo er es noch nicht kann, bleibt ein Schiebefeld.' });
|
||||
for (const [key, label] of [['desktopVisible', 'Sichtbar auf Desktop'], ['tabletVisible', 'Sichtbar auf Tablet'], ['mobileVisible', 'Sichtbar auf Mobil']]) {
|
||||
field(label, key, block.props[key], { number: true, help: key === 'desktopVisible' ? 'Wie viele Folien gleichzeitig zu sehen sind, 1 bis 6.' : undefined });
|
||||
@@ -419,9 +549,33 @@ function renderContent(block, panel) {
|
||||
field('Höhe (px)', 'height', block.props.height, { number: true, help: 'Zwischen 150 und 1200.' });
|
||||
field('Hinweis im Platzhalter', 'hint', block.props.hint || '', { multiline: true, help: 'Leer lassen für den Standardtext.' });
|
||||
}
|
||||
if (block.type === 'form') field('Adresse des Formulars', 'form', block.props.form, { help: 'Die Adresse aus der Formularverwaltung, zum Beispiel kontakt. Felder, Empfänger und Einwilligungstext werden dort gepflegt, nicht hier.' });
|
||||
if (block.type === 'html') {
|
||||
// Rohes Markup. Dieselbe Rolle wie beim eigenen Skript, deshalb dieselbe Anzeige: sichtbar
|
||||
// fuer alle, beschreibbar nur fuer Administratoren - und der Server weist es ohnehin ab.
|
||||
const label = document.createElement('label'); label.htmlFor = 'own-html'; label.textContent = 'HTML';
|
||||
const input = document.createElement('textarea');
|
||||
input.id = 'own-html'; input.rows = 10; input.spellcheck = false; input.className = 'own-code';
|
||||
input.value = block.props?.html ?? '';
|
||||
input.readOnly = !mayScript;
|
||||
input.addEventListener('change', () => {
|
||||
history.change(doc => {
|
||||
upgradeDocument(doc, 24);
|
||||
const current = locate(doc.blocks, block.id)?.block;
|
||||
if (!current) return;
|
||||
current.props = { ...current.props, html: input.value };
|
||||
});
|
||||
changed();
|
||||
});
|
||||
const note = document.createElement('p'); note.className = 'field-help';
|
||||
note.innerHTML = mayScript
|
||||
? 'Markup, das unverändert auf der Seite landet – eigene Tabellen, fremde Widgets, Auszeichnungen, die es als Baustein nicht gibt. <strong><script></strong> gehört unter „Erweitert“ in das Feld „Eigenes Skript“, ein <strong><iframe></strong> in den Baustein „Externer Inhalt“ – nur der fragt vorher um Einwilligung. Die Vorschau zeigt das HTML so, wie die Seite es zeigt.'
|
||||
: 'Eigenes HTML kann nur ein Administrator ändern, weil es unverändert auf der veröffentlichten Seite landet. Der Inhalt ist hier nur zu sehen.';
|
||||
panel.append(label, input, note);
|
||||
}
|
||||
if (block.type === 'form') field('Formular', 'form', block.props.form, { ...pick('forms', block.props.form), help: 'Felder, Empfänger und Einwilligungstext werden unter „Formulare“ gepflegt, nicht hier.' });
|
||||
if (block.type === 'tabitem') field('Titel', 'title', block.props.title, { maxLength: 160, help: 'Beschriftung des Tabs. In der Vorschau sind alle Tabs untereinander sichtbar, weil die Vorschau kein JavaScript ausführt.' });
|
||||
if (block.type === 'accordion') {
|
||||
field('Als FAQ auszeichnen', 'faq', block.props?.faq === true, { checkbox: true, help: 'Die Einträge erscheinen als Frage-Antwort-Paare in den strukturierten Daten (FAQPage): der Titel ist die Frage, die Textbausteine im Eintrag sind die Antwort.' });
|
||||
const hint = document.createElement('p'); hint.className = 'field-help';
|
||||
hint.textContent = 'Wähle einen Eintrag, um seinen Titel und Inhalt zu bearbeiten. Texte, Bilder und weitere Elemente gehören in den jeweiligen Eintrag.';
|
||||
const entries = document.createElement('div'); entries.className = 'accordion-entry-list';
|
||||
@@ -436,7 +590,7 @@ function renderContent(block, panel) {
|
||||
add.textContent = '+ Eintrag hinzufügen'; add.addEventListener('click', () => addAccordionEntry(block.id));
|
||||
panel.append(hint, entries, add);
|
||||
}
|
||||
if (['section', 'row', 'column'].includes(block.type)) field('Sprungziel', 'anchor', block.anchor, { anchor: true, maxLength: 64, help: 'Zum Beispiel kontakt eingeben. Buttons und Menülinks mit #kontakt springen dann hierher; von anderen Seiten mit /seitenadresse#kontakt.' });
|
||||
if (['section', 'row', 'column'].includes(block.type)) field('Sprungziel', 'anchor', block.anchor, { anchor: true, maxLength: 64, help: 'Zum Beispiel kontakt. Im Menü lässt es sich dann als Sprungziel dieser Seite auswählen, ein Button springt mit #kontakt hierher.' });
|
||||
if (block.type === 'accordionitem') {
|
||||
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
||||
field('Beim Laden geöffnet', 'open', block.props.open, { checkbox: true, help: 'Gilt für die veröffentlichte Seite. Im Editor bleiben alle Einträge zum Bearbeiten geöffnet.' });
|
||||
@@ -448,30 +602,39 @@ function renderContent(block, panel) {
|
||||
if (block.type === 'legal') {
|
||||
field('Beschriftung für Vorlesesoftware', 'label', block.props?.label || 'Rechtliches', { maxLength: 60, help: 'Der Baustein zeigt Impressum, Datenschutz und – sobald jemand gefragt wurde – den Weg zurück zur Einwilligung. Die Adressen stehen in den Einstellungen. Platziere ihn dort, wo die Links erscheinen sollen, zum Beispiel im Fußbereich.' });
|
||||
}
|
||||
if (block.type === 'language') {
|
||||
segmented('Anzeige', 'display', block.props.display === 'name' ? 'name' : 'code', [['code', 'Kürzel (DE · EN)'], ['name', 'Namen (Deutsch · English)']], 27);
|
||||
const help = document.createElement('p'); help.className = 'field-help';
|
||||
help.textContent = 'Zeigt dieselbe Seite in jeder Sprache, in der sie veröffentlicht ist. Die Sprachen stehen unter Einstellungen → Sprachen. Auf einer Seite, die es nur in einer Sprache gibt, bleibt die Sprachwahl leer. Am besten in den Kopfbereich setzen.';
|
||||
panel.append(help);
|
||||
}
|
||||
if (block.type === 'postnav') {
|
||||
field('Beschriftung zurück', 'previousLabel', block.props.previousLabel, { maxLength: 60 });
|
||||
field('Beschriftung weiter', 'nextLabel', block.props.nextLabel, { maxLength: 60, help: 'Die Beiträge selbst sucht die Website beim Ausliefern - innerhalb derselben Kategorie.' });
|
||||
}
|
||||
if (block.type === 'postlist') {
|
||||
field('Anzahl Beiträge', 'count', block.props.count, { number: true, help: 'Zwischen 1 und 24. Die Beiträge selbst kommen beim Ausliefern dazu, nicht aus dem gespeicherten Inhalt.' });
|
||||
field('Nur Kategorie', 'category', block.props.category || '', { help: 'Adresse der Kategorie, zum Beispiel projekte. Leer lassen für alle Beiträge.' });
|
||||
field('Nur Schlagwort', 'tag', block.props.tag || '', { help: 'Adresse des Schlagworts. Leer lassen für alle Beiträge.' });
|
||||
field('Nur Kategorie', 'category', block.props.category || '', { ...pick('categories', block.props.category, 'Alle Kategorien') });
|
||||
field('Nur Schlagwort', 'tag', block.props.tag || '', { ...pick('tags', block.props.tag, 'Alle Schlagwörter'), help: 'Kategorien und Schlagwörter entstehen in den Einstellungen eines Beitrags.' });
|
||||
field('Darstellung', 'layout', block.props.layout || 'karten', { choices: { karten: 'Karten (Raster mit Bild oben)', liste: 'Liste (Bild links, Text rechts)', kompakt: 'Kompakt (nur Titel und Datum)', wechsel: 'Wechselseitig (Bild mal links, mal rechts)' }, help: 'Dieselben Beiträge, drei Darstellungen. Karten für eine Übersicht, Liste für ein Archiv, Kompakt für eine Randspalte.' });
|
||||
field('Blättern statt abschneiden', 'paginate', block.props.paginate === true, { checkbox: true, help: 'Für eine Übersichtsseite. Eine Auswahl auf der Startseite lässt man aus.' });
|
||||
field('Datum und Autor zeigen', 'showMeta', block.props.showMeta !== false, { checkbox: true });
|
||||
field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true });
|
||||
field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' });
|
||||
}
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse.' });
|
||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
|
||||
if (block.type === 'image') {
|
||||
renderImageWidth(block, panel);
|
||||
imagePreview = createMediaPreview({ src: block.props.sources?.[0]?.src || block.props.src, label: block.props.alt || block.props.caption });
|
||||
panel.append(imagePreview.element);
|
||||
const linkOptions = document.createElement('div'); linkOptions.className = 'image-link-options';
|
||||
const showLinkOptions = value => { linkOptions.hidden = value !== 'link'; };
|
||||
const clickAction = block.props?.click || (block.props?.href ? 'link' : 'keine');
|
||||
// Ohne eigene Wahl folgt das Bild seiner Galerie - dieselbe Regel wie in der Vorlage.
|
||||
const holder = locate(history.document.blocks, block.id)?.parent;
|
||||
const galleryZoom = holder?.type === 'gallery' && holder.props?.lightbox === true;
|
||||
const clickAction = block.props?.click || (block.props?.href ? 'link' : (galleryZoom ? 'vergroessern' : 'keine'));
|
||||
field('Beim Klick', 'click', clickAction, { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, onInput: showLinkOptions });
|
||||
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse. Für ein Logo im Kopfbereich meist /.' });
|
||||
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF. Für ein Logo im Kopfbereich meist /.' });
|
||||
showLinkOptions(clickAction); panel.append(linkOptions);
|
||||
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
|
||||
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
|
||||
@@ -486,7 +649,7 @@ function renderContent(block, panel) {
|
||||
field('Bildunterschrift', 'caption', block.props.caption || '', { multiline: true, maxLength: 500 });
|
||||
}
|
||||
if (block.type === 'gallery') {
|
||||
field('Bilder vergrößerbar', 'lightbox', block.props?.lightbox === true, { checkbox: true, help: 'Ein Klick öffnet das Bild über der Seite, mit Blättern durch die Galerie. Die einzelnen Bilder brauchen dafür „Beim Klick: Bild vergrößern".' });
|
||||
field('Bilder vergrößerbar', 'lightbox', block.props?.lightbox === true, { checkbox: true, help: 'Gilt für alle Bilder der Galerie: ein Klick öffnet das Bild über der Seite, mit Blättern durch die Galerie. Ein Bild mit eigener Einstellung unter „Beim Klick“ behält sie.' });
|
||||
const hint = document.createElement('p'); hint.className = 'panel-hint'; hint.textContent = `${block.children?.length || 0} Bilder · Bilder im Strukturbaum auswählen, beschriften und mit den Pfeilen sortieren. Spalten und Abstände stehen unter Design.`;
|
||||
const picker = document.createElement('button'); picker.className = 'primary'; picker.textContent = 'Galeriebilder auswählen';
|
||||
picker.addEventListener('click', () => chooseMedia(true)); panel.append(hint, picker);
|
||||
@@ -550,7 +713,6 @@ function renderAdvanced(block, panel) {
|
||||
: 'Eigenes Skript kann nur ein Administrator ändern, weil es Code im Browser jedes Besuchers ausführt. Der Inhalt ist hier nur zu sehen.', 7);
|
||||
if (!mayScript) {
|
||||
scriptInput.readOnly = true;
|
||||
scriptInput.setAttribute('aria-describedby', 'own-script-locked');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,7 +788,7 @@ function renderLegacyTokens(block, panel) {
|
||||
// Token based values from documents written before schema v10. Shown only while a block
|
||||
// still carries them, so older pages stay editable without cluttering the new panel.
|
||||
const hint = document.createElement('p'); hint.className = 'design-scope';
|
||||
hint.textContent = `${devices[device]} · ${device === 'desktop' ? 'Basis für alle Geräte' : device === 'tablet' ? 'bis 1024 px; gilt auch für Mobil' : 'bis 650 px'}`;
|
||||
hint.textContent = `${devices[device]} · ${device === 'desktop' ? 'Basis für alle Geräte' : device === 'tablet' ? 'bis 1024 px; gilt auch für Mobil' : 'bis 767 px'}`;
|
||||
const help = document.createElement('p'); help.className = 'field-help'; help.textContent = 'Oben die Vorschaugröße wählen. Ohne eigenen Wert gilt die Einstellung des größeren Geräts oder des Themes.';
|
||||
panel.append(hint, help);
|
||||
for (const [key, token] of Object.entries(designTokens)) {
|
||||
@@ -672,14 +834,45 @@ function renderLegacyEffects(block, panel) {
|
||||
select.addEventListener('change', () => { history.change(doc => setEffect(doc, selected, key, select.value)); controls(); schedulePreview(); });
|
||||
panel.append(labelEl, select);
|
||||
}
|
||||
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Einblendungen starten, sobald das Element im Bildschirm erscheint. Mit „Bewegung testen“ lässt sich das in der Vorschau prüfen. Die Einstellung „Bewegung reduzieren“ wird berücksichtigt.'; panel.append(note);
|
||||
const note = document.createElement('p'); note.className = 'field-help'; note.textContent = 'Einblendungen starten, sobald das Element im Bildschirm erscheint. Mit dem Abspielknopf über der Vorschau lässt sich das prüfen. Die Einstellung „Bewegung reduzieren“ wird berücksichtigt.'; panel.append(note);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Felder eines Plugin-Bausteins, aus dem Schema seines Manifests erzeugt. Dieselbe
|
||||
* Feldfabrik wie für Kernbausteine; nur ein Bild braucht die Mediathek.
|
||||
*/
|
||||
function renderPluginFields(block, panel, plugin, field) {
|
||||
if (plugin.description) { const hint = document.createElement('p'); hint.className = 'panel-hint'; hint.textContent = plugin.description; panel.append(hint); }
|
||||
for (const [key, schema] of Object.entries(plugin.props || {})) {
|
||||
const value = block.props?.[key] ?? schema.default;
|
||||
const options = { help: schema.help || undefined };
|
||||
if (schema.kind === 'textarea') field(schema.label, key, value, { ...options, multiline: true, maxLength: schema.maxLength });
|
||||
else if (schema.kind === 'number') field(schema.label, key, value, { ...options, number: true, min: schema.min, max: schema.max });
|
||||
else if (schema.kind === 'boolean') field(schema.label, key, value === true, { ...options, checkbox: true });
|
||||
else if (schema.kind === 'select') field(schema.label, key, value, { ...options, choices: schema.choices });
|
||||
else if (schema.kind === 'media') {
|
||||
const wrap = document.createElement('div'); wrap.className = 'plugin-media-field';
|
||||
const label = document.createElement('p'); label.className = 'field-label'; label.textContent = schema.label; wrap.append(label);
|
||||
const preview = createMediaPreview({ mediaId: value || '' }); wrap.append(preview.element);
|
||||
const set = id => { history.change(doc => { const current = locate(doc.blocks, block.id)?.block; if (current) (current.props ??= {})[key] = id; }); renderProperties(); changed(); };
|
||||
const pick = document.createElement('button'); pick.type = 'button'; pick.className = 'secondary'; pick.textContent = value ? 'Anderes Bild wählen' : 'Bild aus Mediathek wählen';
|
||||
pick.addEventListener('click', () => openMedia({ multiple: false, onChoose: items => { if (items[0]?.id) set(items[0].id); } }));
|
||||
wrap.append(pick);
|
||||
if (value) { const remove = document.createElement('button'); remove.type = 'button'; remove.className = 'text-button danger-text'; remove.textContent = 'Bild entfernen'; remove.addEventListener('click', () => set('')); wrap.append(remove); }
|
||||
if (schema.help) { const help = document.createElement('p'); help.className = 'field-help'; help.textContent = schema.help; wrap.append(help); }
|
||||
panel.append(wrap);
|
||||
}
|
||||
else field(schema.label, key, value, { ...options, maxLength: schema.maxLength, plugin: true, link: schema.kind === 'url' });
|
||||
}
|
||||
}
|
||||
|
||||
function chooseMedia(multiple) {
|
||||
const target = selected;
|
||||
openMedia({ multiple, onChoose: items => {
|
||||
if (!items.length || busy) return;
|
||||
selected = history.change(doc => insertMedia(doc, target, items)); changed();
|
||||
const id = history.change(doc => insertMedia(doc, target, items));
|
||||
if (id === null) { notify('Hier lassen sich keine Bilder einfügen – wähle eine Sektion, Spalte oder Galerie.', true); return; }
|
||||
selected = id; changed();
|
||||
} });
|
||||
}
|
||||
|
||||
@@ -799,7 +992,7 @@ async function updatePreview() {
|
||||
const sequence = previewSequence;
|
||||
const controller = new AbortController(); previewController = controller;
|
||||
try {
|
||||
const html = await request('/preview', { document: history.document }, controller.signal);
|
||||
const html = await request('/preview', { document: history.document, language }, controller.signal);
|
||||
if (sequence !== previewSequence) return;
|
||||
const frame = $('#preview');
|
||||
const top = frame.contentWindow?.scrollY || 0;
|
||||
@@ -840,7 +1033,7 @@ async function updatePreview() {
|
||||
menuSummary.parentElement.open = !menuSummary.parentElement.open;
|
||||
}
|
||||
const insert = event.target.closest('[data-editor-target]');
|
||||
if (insert && !busy && history) {
|
||||
if (insert && !busy && history && !language) {
|
||||
stopInlineEdit();
|
||||
const id = insert.dataset.editorTarget;
|
||||
if (!locate(history.document.blocks, id)) return;
|
||||
@@ -865,12 +1058,13 @@ async function updatePreview() {
|
||||
const element = event.target.closest('[id]');
|
||||
if (!element) return;
|
||||
const block = locate(history.document.blocks, element.id)?.block;
|
||||
if (language) return;
|
||||
if (block && INLINE_TEXT[block.type]) { event.preventDefault(); choose(element.id, false); startInlineEdit(element.id); }
|
||||
});
|
||||
// Right click: the element's actions where the pointer is, as in Flatsome.
|
||||
doc.addEventListener('contextmenu', event => {
|
||||
const element = event.target.closest('[id]');
|
||||
if (!element || !locate(history.document.blocks, element.id) || busy || inlineEdit) return;
|
||||
if (!element || !locate(history.document.blocks, element.id) || busy || inlineEdit || language) return;
|
||||
event.preventDefault();
|
||||
choose(element.id, false);
|
||||
openContextMenu(element.id, event.clientX, event.clientY);
|
||||
@@ -886,7 +1080,8 @@ async function updatePreview() {
|
||||
// The head may arrive before the blocks. Decorate only once the complete DOM exists.
|
||||
const finish = () => {
|
||||
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
|
||||
decorateContainers(doc, history.document);
|
||||
// Beim Übersetzen gibt es nichts einzufügen: der Aufbau gehört der Hauptsprache.
|
||||
if (!language) decorateContainers(doc, history.document);
|
||||
overlay?.attach();
|
||||
styleReady.then(loaded => {
|
||||
if (sequence !== previewSequence || doc !== frame.contentDocument) return;
|
||||
@@ -896,7 +1091,9 @@ async function updatePreview() {
|
||||
frame.style.visibility = '';
|
||||
showMotionStatus();
|
||||
playPreview();
|
||||
$('#preview-status').textContent = loaded
|
||||
$('#preview-status').textContent = loaded && language
|
||||
? `Entwurf auf ${languageName(language)} · Klicken springt zum Text in der Übersetzungsliste. Leere Felder zeigen den Text der Hauptsprache.`
|
||||
: loaded
|
||||
? 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.'
|
||||
: 'Editor-Hilfsflächen konnten nicht geladen werden. Bitte den Editor neu laden.';
|
||||
});
|
||||
@@ -935,7 +1132,226 @@ function changed() {
|
||||
if (!locate(history.document.blocks, selected)) selected = null;
|
||||
if (selected && !$('#preview').contentDocument?.getElementById(selected)) revealAfterPreview = selected;
|
||||
syncView();
|
||||
renderTree(); renderProperties(); controls(); schedulePreview();
|
||||
renderTree(); renderProperties(); renderTranslations(); controls(); schedulePreview();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Übersetzen (ADR 111).
|
||||
//
|
||||
// Dieselbe Seite, dieselbe Vorschau - nur die Seitenleiste zeigt statt Werkzeugkasten und
|
||||
// Eigenschaften die Liste der Texte: Original links darüber, Übersetzung darunter. Aufbau und
|
||||
// Gestaltung bleiben in der Hauptsprache; hier lässt sich nichts verschieben oder löschen.
|
||||
// Jede Eingabe ist ein gewöhnlicher Schritt im Verlauf, also gelten Undo, Speichern und
|
||||
// Veröffentlichen wie immer und für alle Sprachen zugleich.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
const languageName = code => languageInfo?.extra.find(item => item.code === code)?.name ?? languageInfo?.main.name ?? code;
|
||||
|
||||
function renderLanguageSwitch() {
|
||||
const holder = $('#language-switch');
|
||||
const extra = languageInfo?.extra ?? [];
|
||||
holder.hidden = !extra.length;
|
||||
$('#publish-languages').hidden = !extra.length;
|
||||
holder.replaceChildren();
|
||||
if (!extra.length) return;
|
||||
for (const item of [languageInfo.main, ...extra]) {
|
||||
const button = document.createElement('button'); button.type = 'button';
|
||||
button.textContent = item.code.toUpperCase();
|
||||
button.title = item === languageInfo.main ? `${item.name} – Hauptsprache: Aufbau, Gestaltung und Texte` : `${item.name} – nur die Texte übersetzen`;
|
||||
// Der Name beginnt mit dem sichtbaren Kürzel, damit auch Sprachsteuerung den Knopf findet.
|
||||
button.setAttribute('aria-label', item === languageInfo.main ? `${item.code.toUpperCase()} – ${item.name} (Hauptsprache) bearbeiten` : `${item.code.toUpperCase()} – ${item.name} übersetzen`);
|
||||
const active = (language ?? languageInfo.main.code) === item.code;
|
||||
button.setAttribute('aria-pressed', String(active));
|
||||
button.addEventListener('click', () => setLanguage(item === languageInfo.main ? null : item.code));
|
||||
holder.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
function setLanguage(code) {
|
||||
if (busy || !history || code === language) return;
|
||||
stopInlineEdit(); closeContextMenu(); setPendingInsert(null);
|
||||
language = code;
|
||||
if (language) root.dataset.language = language; else delete root.dataset.language;
|
||||
selected = null; history.mergeKey = null; translateFilter = 'all';
|
||||
setView(language ? 'translate' : 'elements');
|
||||
renderLanguageSwitch(); renderTree(); renderProperties(); renderTranslations(); controls(); schedulePreview();
|
||||
if (language) $('#translate-panel').querySelector('h2')?.focus();
|
||||
}
|
||||
|
||||
/** Springt in der Liste zu den Texten des gewählten Blocks oder des ersten Blocks darin. */
|
||||
function focusTranslation(id) {
|
||||
const panel = $('#translate-panel');
|
||||
const ids = [];
|
||||
const collect = block => { ids.push(block.id); (block.children ?? []).forEach(collect); };
|
||||
const block = locate(history.document.blocks, id)?.block;
|
||||
if (!block) return;
|
||||
collect(block);
|
||||
const group = ids.map(item => panel.querySelector(`.translate-group[data-block-id="${CSS.escape(item)}"]`)).find(Boolean);
|
||||
if (!group) return;
|
||||
if (group.hidden) { translateFilter = 'all'; renderTranslations(); return focusTranslation(id); }
|
||||
panel.querySelectorAll('.translate-group.is-current').forEach(item => item.classList.remove('is-current'));
|
||||
group.classList.add('is-current');
|
||||
const body = $('.sidebar-body');
|
||||
body.scrollTop += group.getBoundingClientRect().top - body.getBoundingClientRect().top - 12;
|
||||
group.querySelector('textarea,input,[contenteditable="true"]')?.focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
const STATE_TEXT = { missing: 'Fehlt', stale: 'Original geändert', done: 'Übersetzt' };
|
||||
|
||||
function renderTranslations() {
|
||||
const panel = $('#translate-panel');
|
||||
if (!language || !history) { panel.replaceChildren(); return; }
|
||||
const scroll = $('.sidebar-body').scrollTop;
|
||||
const info = languageInfo.extra.find(item => item.code === language);
|
||||
const doc = history.document;
|
||||
const translation = doc.translations?.[language] ?? {};
|
||||
// Ein Kopf- oder Fußbereich hat weder Adresse noch Seitentitel - nur Texte in Blöcken.
|
||||
const list = translationEntries(doc, language).filter(entry => !languageInfo.area || entry.blockId);
|
||||
const count = { total: list.length, done: list.filter(entry => entry.state !== 'missing').length, stale: list.filter(entry => entry.state === 'stale').length };
|
||||
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); if (className) node.className = className; if (text) node.textContent = text; return node; };
|
||||
panel.replaceChildren();
|
||||
|
||||
const head = el('div', 'translate-head');
|
||||
const title = el('h2', '', `Übersetzung: ${info.name}`); title.tabIndex = -1;
|
||||
const meter = el('progress'); meter.max = Math.max(1, count.total); meter.value = count.done; meter.setAttribute('aria-label', 'Übersetzt');
|
||||
const summary = el('p', 'translate-summary', `${count.done} von ${count.total} Texten übersetzt${count.stale ? ` · ${count.stale} mit geändertem Original` : ''}`);
|
||||
summary.setAttribute('role', 'status');
|
||||
const hint = el('p', 'field-help', `Aufbau, Bilder und Gestaltung bearbeitest du in ${languageInfo.main.name}. Hier stehen nur die Texte. Ein leeres Feld zeigt auf der Website den Text in ${languageInfo.main.name}.`);
|
||||
const filter = el('div', 'segmented translate-filter'); filter.setAttribute('role', 'group'); filter.setAttribute('aria-label', 'Texte filtern');
|
||||
filter.style.gridTemplateColumns = 'repeat(3, 1fr)';
|
||||
for (const [value, text] of [['all', 'Alle'], ['missing', 'Fehlend'], ['stale', 'Geändert']]) {
|
||||
const option = el('button', '', text); option.type = 'button'; option.setAttribute('aria-pressed', String(translateFilter === value));
|
||||
option.addEventListener('click', () => { translateFilter = value; renderTranslations(); });
|
||||
filter.append(option);
|
||||
}
|
||||
head.append(title, meter, summary, hint, filter);
|
||||
panel.append(head);
|
||||
|
||||
// Adresse und Suchtitel: gehören nur zur Übersetzung, ein Original gibt es dafür nicht.
|
||||
const page = el('section', 'translate-group translate-page'); page.dataset.blockId = '';
|
||||
if (languageInfo.area) page.hidden = true;
|
||||
page.append(el('h3', '', 'Seite'));
|
||||
const published = languageInfo.published?.[language];
|
||||
if (languageInfo.home) {
|
||||
page.append(el('p', 'field-help', `Startseite – erscheint unter /${language}.`));
|
||||
} else {
|
||||
const label = el('label', '', 'Adresse'); label.htmlFor = 'translate-slug';
|
||||
const wrap = el('div', 'translate-slug');
|
||||
const prefix = el('span', 'translate-prefix', `/${language}/…/`); prefix.setAttribute('aria-hidden', 'true');
|
||||
const input = el('input'); input.id = 'translate-slug'; input.maxLength = 80; input.value = translation.slug ?? '';
|
||||
input.placeholder = languageInfo.slug; input.spellcheck = false; input.autocomplete = 'off';
|
||||
input.pattern = '[a-z0-9]+(-[a-z0-9]+)*';
|
||||
const help = el('p', 'field-help', `Kleinbuchstaben, Ziffern, Bindestriche. Leer: wie in ${languageInfo.main.name} (${languageInfo.slug}).`); help.id = 'translate-slug-help';
|
||||
input.setAttribute('aria-describedby', help.id);
|
||||
input.addEventListener('input', () => { history.change(content => setPageValue(content, language, 'slug', input.value), 'tr-slug'); controls(); });
|
||||
const suggest = el('button', 'text-button', 'Aus dem übersetzten Titel'); suggest.type = 'button';
|
||||
suggest.addEventListener('click', () => {
|
||||
const proposal = slugify(history.document.translations?.[language]?.title ?? '');
|
||||
if (!proposal) { help.textContent = 'Erst den Seitentitel übersetzen, dann lässt sich die Adresse daraus bilden.'; return; }
|
||||
input.value = proposal; history.change(content => setPageValue(content, language, 'slug', proposal)); controls();
|
||||
});
|
||||
wrap.append(prefix, input);
|
||||
page.append(label, wrap, help, suggest);
|
||||
}
|
||||
const seoLabel = el('label', '', 'Titel für Suchmaschinen'); seoLabel.htmlFor = 'translate-seo';
|
||||
const seo = el('input'); seo.id = 'translate-seo'; seo.maxLength = 160; seo.value = translation.seoTitle ?? ''; seo.lang = language;
|
||||
seo.placeholder = 'Leer: der übersetzte Seitentitel';
|
||||
seo.addEventListener('input', () => { history.change(content => setPageValue(content, language, 'seoTitle', seo.value), 'tr-seo'); controls(); });
|
||||
page.append(seoLabel, seo);
|
||||
page.append(el('p', 'field-help', published
|
||||
? `Veröffentlicht unter /${language}${languageInfo.home ? '' : `/${published}`}. Änderungen gehen mit „Veröffentlichen“ online.`
|
||||
: `Noch nicht auf der Website. Die Seite erscheint auf ${info.name}, sobald ein Text übersetzt und die Seite veröffentlicht ist.`));
|
||||
panel.append(page);
|
||||
|
||||
// Die Texte, gruppiert nach Block in Seitenreihenfolge; Seitentitel und Beschreibung vorn.
|
||||
let group = null, groupId;
|
||||
for (const entry of list) {
|
||||
const id = entry.blockId ?? '__page';
|
||||
if (id !== groupId) {
|
||||
groupId = id;
|
||||
group = el('section', 'translate-group'); group.dataset.blockId = entry.blockId ?? '';
|
||||
const heading = el('h3');
|
||||
if (entry.blockId) {
|
||||
const jump = el('button', 'translate-jump', describe(entry.block)); jump.type = 'button';
|
||||
jump.title = 'In der Vorschau zeigen';
|
||||
const current = group;
|
||||
jump.addEventListener('click', () => { selected = entry.blockId; highlight(true); panel.querySelectorAll('.translate-group.is-current').forEach(item => item.classList.remove('is-current')); current.classList.add('is-current'); });
|
||||
heading.append(jump);
|
||||
} else heading.textContent = 'Seitentitel und Beschreibung';
|
||||
group.append(heading);
|
||||
panel.append(group);
|
||||
}
|
||||
group.append(translationField(entry, info));
|
||||
}
|
||||
for (const section of panel.querySelectorAll('.translate-group:not(.translate-page)')) {
|
||||
const fields = [...section.querySelectorAll('.translate-field')];
|
||||
for (const field of fields) field.hidden = translateFilter !== 'all' && field.dataset.state !== translateFilter;
|
||||
section.hidden = fields.every(field => field.hidden);
|
||||
}
|
||||
if (!list.length) panel.append(el('p', 'panel-hint', languageInfo.area ? 'In diesem Bereich gibt es keine Texte zum Übersetzen. Menübeschriftungen übersetzt du unter Menüs.' : 'Auf dieser Seite gibt es noch keine Texte zum Übersetzen.'));
|
||||
else if (languageInfo.area) head.append(el('p', 'field-help', 'Gespeicherte Übersetzungen eines Bereichs gelten sofort auf allen Seiten dieser Sprache. Menübeschriftungen übersetzt du unter Menüs.'));
|
||||
else if (translateFilter !== 'all' && !panel.querySelector('.translate-field:not([hidden])')) panel.append(el('p', 'panel-hint', translateFilter === 'missing' ? 'Alles übersetzt.' : 'Kein Original wurde seit der Übersetzung geändert.'));
|
||||
if (selected) panel.querySelector(`.translate-group[data-block-id="${CSS.escape(selected)}"]`)?.classList.add('is-current');
|
||||
$('.sidebar-body').scrollTop = scroll;
|
||||
controls();
|
||||
}
|
||||
|
||||
/** Kurzname eines Blocks für die Liste: Art und der Anfang des Originals. */
|
||||
function describe(block) {
|
||||
const text = plainText(block.props?.text ?? block.props?.title ?? block.props?.alt ?? block.props?.label ?? '');
|
||||
return text ? `${blockLabel(block)} · ${text.length > 40 ? `${text.slice(0, 40)} …` : text}` : blockLabel(block);
|
||||
}
|
||||
|
||||
function translationField(entry, info) {
|
||||
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); if (className) node.className = className; if (text) node.textContent = text; return node; };
|
||||
const row = el('div', 'translate-field'); row.dataset.state = entry.state; row.dataset.key = entry.key;
|
||||
const id = `tr-${entry.key.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
|
||||
const label = el('label', '', entry.label); label.htmlFor = id;
|
||||
const badge = el('span', 'translate-state', STATE_TEXT[entry.state]);
|
||||
const top = el('div', 'translate-field-head'); top.append(label, badge);
|
||||
const original = el('p', 'translate-original'); original.lang = languageInfo.main.code;
|
||||
original.textContent = entry.field.rich ? plainText(entry.original) : entry.original;
|
||||
const originalId = `${id}-original`; original.id = originalId;
|
||||
const update = value => {
|
||||
history.change(content => setText(content, language, entry.key, value), `tr-${entry.key}`);
|
||||
const state = value.trim() === '' ? 'missing' : 'done';
|
||||
row.dataset.state = state; badge.textContent = STATE_TEXT[state];
|
||||
controls(); schedulePreview();
|
||||
};
|
||||
row.append(top, original);
|
||||
let input;
|
||||
const rich = entry.field.rich === 'text' ? history.document.schemaVersion >= 12 : entry.field.rich === 'heading' ? history.document.schemaVersion >= 17 : false;
|
||||
if (rich) {
|
||||
const editor = createRichText(entry.value, markup => update(plainText(markup) === '' ? '' : markup), { label: `${entry.label} auf ${info.name}` });
|
||||
editor.element.lang = language;
|
||||
if (info.rtl) editor.element.dir = 'rtl';
|
||||
editor.element.querySelector('[contenteditable]')?.setAttribute('aria-describedby', originalId);
|
||||
// Das Label zeigt auf die Fläche, die man beschreibt.
|
||||
const surface = editor.element.querySelector('[contenteditable]'); if (surface) { surface.id = id; label.addEventListener('click', () => surface.focus()); }
|
||||
row.append(editor.element);
|
||||
} else {
|
||||
input = el(entry.field.multiline ? 'textarea' : 'input');
|
||||
input.id = id; input.value = entry.value; input.maxLength = entry.field.max; input.lang = language;
|
||||
if (info.rtl) input.dir = 'rtl';
|
||||
if (entry.field.multiline) input.rows = 3;
|
||||
input.setAttribute('aria-describedby', originalId);
|
||||
input.addEventListener('input', () => update(input.value));
|
||||
row.append(input);
|
||||
}
|
||||
const actions = el('div', 'translate-actions');
|
||||
if (entry.state === 'missing') {
|
||||
const copy = el('button', 'text-button', 'Original übernehmen'); copy.type = 'button';
|
||||
copy.title = 'Den Text der Hauptsprache als Ausgangspunkt einsetzen';
|
||||
copy.addEventListener('click', () => { history.change(content => setText(content, language, entry.key, entry.original)); changed(); });
|
||||
actions.append(copy);
|
||||
}
|
||||
if (entry.state === 'stale') {
|
||||
const ok = el('button', 'text-button', 'Übersetzung passt'); ok.type = 'button';
|
||||
ok.title = 'Das Original wurde geändert, die Übersetzung stimmt trotzdem';
|
||||
ok.addEventListener('click', () => { history.change(content => confirmText(content, language, entry.key)); changed(); });
|
||||
actions.append(ok);
|
||||
}
|
||||
if (actions.childElementCount) row.append(actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function loadDesignSchema() {
|
||||
@@ -969,9 +1385,19 @@ async function load() {
|
||||
designTokens = await response.json();
|
||||
}
|
||||
if (!designSchema) await loadDesignSchema();
|
||||
history = new EditorHistory(data.document);
|
||||
// Ein Dokument mit Eigenschaften einer neueren Fassung wird beim Laden gehoben, sonst lehnt
|
||||
// der Server schon die Vorschau ab (siehe repairSchema).
|
||||
history = new EditorHistory(repairSchema(data.document));
|
||||
({ version, revisionId, publishedRevisionId, csrfToken } = data);
|
||||
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify(); changed(); resizePreview();
|
||||
languageInfo = data.languages ?? null;
|
||||
if (language && !languageInfo?.extra.some(item => item.code === language)) { language = null; delete root.dataset.language; }
|
||||
renderLanguageSwitch();
|
||||
// Die Bausteine der eingeschalteten Plugins kommen mit dem Dokument; der Werkzeugkasten
|
||||
// bekommt dafür eine eigene Gruppe.
|
||||
registerPlugins(data.plugins || [], data.presets || []); buildLibrary(); buildPluginPresets();
|
||||
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify();
|
||||
if (language) setView('translate');
|
||||
changed(); resizePreview();
|
||||
} catch (error) { notify(error.message, true); }
|
||||
finally { busy = false; controls(); }
|
||||
}
|
||||
@@ -983,7 +1409,15 @@ async function save(publish = false) {
|
||||
try {
|
||||
const data = await request(publish ? '/publish' : '/draft', publish ? { expectedVersion: version, revisionId } : { expectedVersion: version, document: snapshot });
|
||||
({ version, revisionId, publishedRevisionId, csrfToken } = data);
|
||||
history.markSaved(snapshot);
|
||||
if (data.languages) languageInfo = data.languages;
|
||||
// Der Server bereinigt Rich Text beim Speichern. Weicht seine Fassung ab, uebernimmt der
|
||||
// Editor sie - als gespeicherter Stand, ohne den Verlauf zu verlieren.
|
||||
if (!publish && data.document && JSON.stringify(data.document) !== JSON.stringify(snapshot) && JSON.stringify(history.document) === JSON.stringify(snapshot)) {
|
||||
history.adopt(data.document);
|
||||
history.markSaved(data.document);
|
||||
renderTree(); renderProperties(); renderTranslations(); schedulePreview();
|
||||
} else history.markSaved(snapshot);
|
||||
if (publish && language) renderTranslations();
|
||||
notify();
|
||||
} catch (error) { notify(error.message, true); }
|
||||
finally { busy = false; controls(); }
|
||||
@@ -1060,6 +1494,23 @@ function markInsertTarget() {
|
||||
// editor describes in its own hint was not the structure the buttons followed.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Die Seitenvorlagen der Plugins, hinter den eingebauten, mit demselben Knopf. */
|
||||
function buildPluginPresets() {
|
||||
const presets = $('#presets');
|
||||
presets.querySelectorAll('[data-plugin-preset]').forEach(button => button.remove());
|
||||
for (const preset of Object.values(PLUGIN_PRESETS)) {
|
||||
const button = document.createElement('button'); button.type = 'button'; button.dataset.preset = preset.id; button.dataset.pluginPreset = '';
|
||||
button.title = preset.description ? `${preset.description} (${preset.plugin})` : preset.plugin;
|
||||
const icon = document.createElement('span'); icon.setAttribute('aria-hidden', 'true'); icon.textContent = '⬡';
|
||||
button.append(icon, document.createTextNode(` ${preset.label}`));
|
||||
presets.append(button);
|
||||
}
|
||||
controls();
|
||||
}
|
||||
|
||||
/** Die Zeichnung für einen Plugin-Baustein im Werkzeugkasten: ein Steckstück. */
|
||||
const PLUGIN_PREVIEW = '<svg viewBox="0 0 64 40" aria-hidden="true"><path d="M20 8h-8v24h40V8h-8v4a4 4 0 0 1-8 0V8h-8v4a4 4 0 0 1-8 0Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>';
|
||||
|
||||
function buildLibrary() {
|
||||
const library = $('#library');
|
||||
library.replaceChildren();
|
||||
@@ -1087,7 +1538,7 @@ function buildLibrary() {
|
||||
const picture = document.createElement('span');
|
||||
picture.className = 'library-preview';
|
||||
// Drawn, not written: the shapes come from this editor and carry no untrusted text.
|
||||
picture.innerHTML = columns ? rowPreview(columns) : preview(type);
|
||||
picture.innerHTML = columns ? rowPreview(columns) : (preview(type) || PLUGIN_PREVIEW);
|
||||
const text = document.createElement('span');
|
||||
text.className = 'library-name';
|
||||
text.textContent = name;
|
||||
@@ -1293,7 +1744,9 @@ $('#delete').addEventListener('click', () => actions.delete(selected));
|
||||
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => actions.move(selected, direction));
|
||||
$('#nest').addEventListener('click', () => { history.change(doc => { const item = locate(doc.blocks, selected); const previous = item.siblings[item.index - 1]; if (previous) moveBlock(doc, selected, previous.id, true); }); changed(); });
|
||||
$('#outdent').addEventListener('click', () => {
|
||||
history.change(doc => { const item = locate(doc.blocks, selected); if (!item?.parent) return; const parent = locate(doc.blocks, item.parent.id); item.siblings.splice(item.index, 1); parent.siblings.splice(parent.index + 1, 0, item.block); }); changed();
|
||||
// Derselbe Weg wie ein Zug im Baum, mit denselben Regeln: neben den Elternblock, sofern der
|
||||
// Grosselternblock den Baustein nimmt.
|
||||
history.change(doc => { const item = locate(doc.blocks, selected); if (!item?.parent) return; const parent = locate(doc.blocks, item.parent.id); moveBlockTo(doc, selected, parent.parent?.id ?? null, parent.index + 1); }); changed();
|
||||
});
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The preview as a working surface: outline, element toolbar, insert points, drag and drop.
|
||||
@@ -1305,18 +1758,26 @@ overlay = createCanvasOverlay({
|
||||
getDocument: () => history.document,
|
||||
getSelected: () => selected,
|
||||
onAction: (action, id) => {
|
||||
if (!id || busy || !history) return;
|
||||
if (!id || busy || !history || language) return;
|
||||
if (action === 'up') actions.move(id, -1);
|
||||
else if (action === 'down') actions.move(id, 1);
|
||||
else actions[action]?.(id);
|
||||
},
|
||||
onInsert: (id, where) => {
|
||||
if (language) return;
|
||||
const item = locate(history.document.blocks, id);
|
||||
if (!item) return;
|
||||
setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + (where === 'after' ? 1 : 0) });
|
||||
},
|
||||
// The border between two columns was dragged (or nudged with the arrow keys): the shares of
|
||||
// the whole row for the current device, one undo step per drag.
|
||||
onResize: (rowId, value) => {
|
||||
if (busy || !history || language) return;
|
||||
history.change(doc => setStyle(doc, rowId, device, 'columnWidths', value));
|
||||
changed();
|
||||
},
|
||||
onDrop: (payload, plan) => {
|
||||
if (busy || !history) return;
|
||||
if (busy || !history || language) return;
|
||||
if (payload.kind === 'add') {
|
||||
const id = history.change(doc => payload.columns
|
||||
? insertRowAt(doc, payload.columns, plan.parentId, plan.index)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:12px}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)}
|
||||
.block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:var(--k-image-radius,12px)}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)}
|
||||
@media(max-width:1024px){.block-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:650px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
|
||||
@media(max-width:767px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
|
||||
.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
/*
|
||||
* The site icon in the settings: an image from the media library (uploads happen right there in
|
||||
* the dialog), then a square cut-out chosen by dragging. The crop is stored as fractions -
|
||||
* left and top of the width and height, the edge of the shorter side - so it holds for every
|
||||
* variant of the image, and the server cuts the PNG files from it when the form is saved.
|
||||
*/
|
||||
const field = document.querySelector('[data-favicon-field]');
|
||||
if (field) setup(field);
|
||||
|
||||
function setup(field) {
|
||||
const idInput = field.querySelector('[name="faviconMediaId"]');
|
||||
const cropInput = field.querySelector('[name="faviconCrop"]');
|
||||
const choose = field.querySelector('[data-favicon-choose]');
|
||||
const remove = field.querySelector('[data-favicon-remove]');
|
||||
const current = field.querySelector('.favicon-current');
|
||||
const base = document.querySelector('meta[name="admin-base"]').content;
|
||||
|
||||
const editor = document.createElement('div'); editor.className = 'favicon-editor'; editor.hidden = true;
|
||||
editor.innerHTML = `<div class="favicon-stage"><img alt="" draggable="false"><div class="favicon-frame" tabindex="0" role="application" aria-label="Ausschnitt für das Symbol. Mit den Pfeiltasten verschieben, mit Umschalt in großen Schritten, mit Plus und Minus die Größe ändern."><span class="favicon-handle" aria-hidden="true"></span></div></div>
|
||||
<div class="favicon-side">
|
||||
<label for="favicon-size">Größe des Ausschnitts</label>
|
||||
<input id="favicon-size" type="range" min="2" max="100" step="1" value="100">
|
||||
<p class="field-help">Quadrat im Bild verschieben, an der Ecke oder mit dem Regler vergrößern und verkleinern.</p>
|
||||
<div class="favicon-samples" aria-label="Vorschau">
|
||||
<figure><canvas width="180" height="180" class="favicon-sample-large"></canvas><figcaption>Startbildschirm</figcaption></figure>
|
||||
<figure><canvas width="32" height="32" class="favicon-sample-small"></canvas><figcaption>Browser-Reiter</figcaption></figure>
|
||||
</div>
|
||||
<button type="button" class="text-button" data-favicon-center>Mittig ausrichten</button>
|
||||
</div>`;
|
||||
field.after(editor);
|
||||
const stage = editor.querySelector('.favicon-stage');
|
||||
const image = stage.querySelector('img');
|
||||
const frame = stage.querySelector('.favicon-frame');
|
||||
const size = editor.querySelector('#favicon-size');
|
||||
const large = editor.querySelector('.favicon-sample-large');
|
||||
const small = editor.querySelector('.favicon-sample-small');
|
||||
|
||||
let crop = { x: 0, y: 0, s: 1 };
|
||||
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
||||
const edge = () => crop.s * Math.min(image.naturalWidth, image.naturalHeight);
|
||||
|
||||
/** Keep the square inside the picture, then show it and write it into the form. */
|
||||
const apply = () => {
|
||||
const w = image.naturalWidth, h = image.naturalHeight; if (!w || !h) return;
|
||||
crop.s = clamp(crop.s, 0.02, 1);
|
||||
const e = edge();
|
||||
crop.x = clamp(crop.x, 0, (w - e) / w); crop.y = clamp(crop.y, 0, (h - e) / h);
|
||||
Object.assign(frame.style, { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${e / w * 100}%`, height: `${e / h * 100}%` });
|
||||
size.value = Math.round(crop.s * 100);
|
||||
cropInput.value = [crop.x, crop.y, crop.s].map(value => String(Math.round(value * 1e6) / 1e6)).join(',');
|
||||
for (const canvas of [large, small]) {
|
||||
const context = canvas.getContext('2d'); context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(image, crop.x * w, crop.y * h, e, e, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
};
|
||||
const center = () => {
|
||||
const w = image.naturalWidth, h = image.naturalHeight, e = edge();
|
||||
crop.x = (w - e) / 2 / w; crop.y = (h - e) / 2 / h; apply();
|
||||
};
|
||||
/** Resize around the middle of the square, so the slider and the keys do not drift it away. */
|
||||
const resize = s => {
|
||||
const w = image.naturalWidth, h = image.naturalHeight, before = edge();
|
||||
crop.s = clamp(s, 0.02, 1); const after = edge();
|
||||
crop.x += (before - after) / 2 / w; crop.y += (before - after) / 2 / h; apply();
|
||||
};
|
||||
|
||||
const load = (src, initial) => new Promise((resolve, reject) => {
|
||||
image.onload = () => { editor.hidden = false; crop = initial || { x: 0, y: 0, s: 1 }; if (initial) apply(); else center(); resolve(); };
|
||||
image.onerror = () => reject(new Error('Das Bild konnte nicht geladen werden.'));
|
||||
image.src = src;
|
||||
});
|
||||
const largest = item => item.variants.at(-1).src;
|
||||
|
||||
const showCurrent = label => {
|
||||
current.replaceChildren(); current.append(document.createTextNode(label));
|
||||
choose.textContent = idInput.value ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen';
|
||||
remove.hidden = !idInput.value;
|
||||
};
|
||||
|
||||
choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: async items => {
|
||||
if (!items.length) return;
|
||||
idInput.value = items[0].id; cropInput.value = '';
|
||||
showCurrent(`Neues Symbol aus „${items[0].title}“ – wird beim Speichern übernommen.`);
|
||||
try { await load(largest(items[0])); frame.focus({ preventScroll: true }); editor.scrollIntoView({ block: 'nearest' }); }
|
||||
catch (error) { showCurrent(error.message); }
|
||||
} }));
|
||||
remove.addEventListener('click', () => {
|
||||
idInput.value = ''; cropInput.value = ''; editor.hidden = true; image.removeAttribute('src');
|
||||
showCurrent('Mitgeliefertes Zeichen – wird beim Speichern übernommen.'); choose.focus();
|
||||
});
|
||||
editor.querySelector('[data-favicon-center]').addEventListener('click', center);
|
||||
size.addEventListener('input', () => resize(size.value / 100));
|
||||
|
||||
// Drag to move, drag the corner to resize. Pointer events cover mouse, pen and touch alike.
|
||||
let drag = null;
|
||||
frame.addEventListener('pointerdown', event => {
|
||||
event.preventDefault(); frame.setPointerCapture(event.pointerId); frame.focus({ preventScroll: true });
|
||||
drag = { mode: event.target.classList.contains('favicon-handle') ? 'resize' : 'move', startX: event.clientX, startY: event.clientY, crop: { ...crop }, rect: stage.getBoundingClientRect() };
|
||||
});
|
||||
frame.addEventListener('pointermove', event => {
|
||||
if (!drag) return;
|
||||
const w = image.naturalWidth, h = image.naturalHeight, { rect } = drag;
|
||||
const dx = (event.clientX - drag.startX) / rect.width, dy = (event.clientY - drag.startY) / rect.height;
|
||||
if (drag.mode === 'move') { crop.x = drag.crop.x + dx; crop.y = drag.crop.y + dy; }
|
||||
else {
|
||||
// The corner follows the larger of both movements, measured in image pixels.
|
||||
const grow = Math.max(dx * w, dy * h), shorter = Math.min(w, h);
|
||||
const room = Math.min(w - drag.crop.x * w, h - drag.crop.y * h);
|
||||
crop.x = drag.crop.x; crop.y = drag.crop.y;
|
||||
crop.s = clamp(drag.crop.s * shorter + grow, 0.02 * shorter, room) / shorter;
|
||||
}
|
||||
apply();
|
||||
});
|
||||
const stop = () => { drag = null; };
|
||||
frame.addEventListener('pointerup', stop); frame.addEventListener('pointercancel', stop);
|
||||
frame.addEventListener('keydown', event => {
|
||||
const step = event.shiftKey ? 0.1 : 0.01;
|
||||
const moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
|
||||
if (moves[event.key]) { event.preventDefault(); crop.x += moves[event.key][0]; crop.y += moves[event.key][1]; apply(); }
|
||||
else if (['+', '='].includes(event.key)) { event.preventDefault(); resize(crop.s + step); }
|
||||
else if (event.key === '-') { event.preventDefault(); resize(crop.s - step); }
|
||||
});
|
||||
|
||||
// An icon that is already set opens with its picture and its cut-out.
|
||||
if (/^[a-f0-9]{32}$/.test(idInput.value)) {
|
||||
fetch(`${base}/api/media/${idInput.value}`, { credentials: 'same-origin' })
|
||||
.then(response => { if (!response.ok || response.redirected) throw new Error(); return response.json(); })
|
||||
.then(({ item }) => {
|
||||
const parts = cropInput.value.split(',').map(Number);
|
||||
return load(largest(item), parts.length === 3 && parts.every(Number.isFinite) ? { x: parts[0], y: parts[1], s: parts[2] } : null);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -19,6 +19,19 @@ Dziedzic, Reserved Font Name „Lato". Die Lizenz erlaubt ausdrücklich, die Dat
|
||||
von einem eigenen Server auszuliefern; verboten ist der Verkauf der Schrift für sich genommen und
|
||||
die Verwendung des reservierten Namens für eine geänderte Fassung.
|
||||
|
||||
**Cormorant Garamond** (Überschriften der Referenzwebsite „Ihr Malerservice Drews") steht ebenfalls
|
||||
unter der SIL Open Font License 1.1, Text in `cormorant-garamond-OFL.txt`. Copyright 2015 The
|
||||
Cormorant Project Authors (github.com/CatharsisFonts/Cormorant), Reserved Font Name „Cormorant".
|
||||
|
||||
**DM Sans** (Fließtext der Seite „Kantarci Markt") steht unter der SIL Open Font License 1.1, Text
|
||||
in `dm-sans-OFL.txt`. Copyright 2014 The DM Sans Project Authors (github.com/googlefonts/dm-fonts).
|
||||
DM Sans ist eine variable Schrift: `dm-sans-400-…`, `-500-…` und `-700-…` sind dieselbe Datei,
|
||||
`fonts.css` nennt nur die erste, mit dem Gewichtsbereich 400–700.
|
||||
|
||||
**Barlow** und **Barlow Condensed** (Fließtext und Überschriften der Seite „Hüfner Gartenbau“)
|
||||
stehen unter der SIL Open Font License 1.1, Text in `barlow-OFL.txt` (für beide Familien gleich).
|
||||
Copyright 2017 The Barlow Project Authors (github.com/jpt/barlow).
|
||||
|
||||
Wer hier andere Schriften ablegt, prüft deren Lizenz selbst: nicht jede Schrift, die Google Fonts
|
||||
ausliefert, steht unter der OFL – einige stehen unter der Apache-Lizenz 2.0, und eine gekaufte
|
||||
Schrift eines Schriftenhauses darf meist gar nicht weitergegeben werden. Die Lizenzdatei gehört in
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2015 the Cormorant Project Authors (github.com/CatharsisFonts/Cormorant)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -10,3 +10,31 @@
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:900;font-display:swap;src:url(/assets/fonts/lato-900-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:900;font-display:swap;src:url(/assets/fonts/lato-900-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/barlow-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/barlow-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:600;font-display:swap;src:url(/assets/fonts/barlow-600-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:600;font-display:swap;src:url(/assets/fonts/barlow-600-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-condensed-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-condensed-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:800;font-display:swap;src:url(/assets/fonts/barlow-condensed-800-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:800;font-display:swap;src:url(/assets/fonts/barlow-condensed-800-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* Der Formulareditor in der Verwaltung.
|
||||
*
|
||||
* Ohne Skript ist die Seite vollständig bedienbar: Felder sind Karten mit Eingaben, ein Feld ohne
|
||||
* Beschriftung wird nicht gespeichert, die Vorschau zeigt den gespeicherten Stand. Das Skript
|
||||
* macht daraus einen Editor: Felder hinzufügen und entfernen, den technischen Namen aus der
|
||||
* Beschriftung ableiten, Optionen nur bei einer Auswahl zeigen, Farben aus der Palette wählen -
|
||||
* und eine Vorschau, die jeder Eingabe folgt, mit dem Standardbutton der Website.
|
||||
*/
|
||||
import { createColorControl } from './color-control.js';
|
||||
import { buttonSample } from './button-design.js';
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
const root = document.querySelector('[data-form-schema]');
|
||||
if (root) {
|
||||
const schema = JSON.parse(root.dataset.formSchema);
|
||||
const bilder = JSON.parse(root.dataset.formImages || '[]');
|
||||
const defaults = {
|
||||
fieldBorderColor: schema.base.color,
|
||||
fieldBackgroundColor: '#ffffff',
|
||||
buttonColor: schema.buttons.backgroundColor,
|
||||
buttonInk: schema.buttons.color,
|
||||
};
|
||||
const hex = value => /^#[0-9a-f]{6}/i.test(value || '') ? value.slice(0, 7) : schema.colors.find(color => `@${color.key}` === value)?.value || '';
|
||||
// Dunkler beim Überfahren, so wie es die Seite selbst tut (82 % Farbe, Rest Schwarz).
|
||||
const darker = value => {
|
||||
const rgb = hex(value); if (!rgb) return value;
|
||||
return '#' + [1, 3, 5].map(index => Math.round(parseInt(rgb.slice(index, index + 2), 16) * 0.82).toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
const luminance = rgb => {
|
||||
const [r, g, b] = [1, 3, 5].map(index => parseInt(rgb.slice(index, index + 2), 16) / 255)
|
||||
.map(channel => channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
const contrast = (back, front) => {
|
||||
if (!back || !front) return null;
|
||||
const [light, dark] = [luminance(back), luminance(front)].sort((a, b) => b - a);
|
||||
return (light + 0.05) / (dark + 0.05);
|
||||
};
|
||||
const slug = text => text.toLowerCase()
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
||||
.replace(/[^a-z0-9]+/g, '_').replace(/^[^a-z]+/, '').replace(/_+$/, '').slice(0, 32);
|
||||
const el = (tag, className, text) => {
|
||||
const node = document.createElement(tag); node.className = className;
|
||||
if (text !== undefined) node.textContent = text; return node;
|
||||
};
|
||||
|
||||
for (const form of root.querySelectorAll('[data-form-editor]')) {
|
||||
const list = form.querySelector('[data-field-list]'), template = form.querySelector('[data-field-template]');
|
||||
const preview = form.querySelector('[data-form-preview]'), add = form.querySelector('[data-add-field]');
|
||||
const buttonLabel = form.querySelector('[data-form-button-label]');
|
||||
const colorInputs = {};
|
||||
const cards = () => [...list.querySelectorAll('[data-field]')];
|
||||
let next = cards().length;
|
||||
|
||||
const renderPreview = () => {
|
||||
preview.replaceChildren();
|
||||
preview.style.color = hex(schema.base.color) || '#203b34';
|
||||
preview.style.background = hex(schema.base.backgroundColor) || '#ffffff';
|
||||
preview.style.setProperty('--form-border', hex(colorInputs.fieldBorderColor.value) || 'currentColor');
|
||||
preview.style.setProperty('--form-bg', hex(colorInputs.fieldBackgroundColor.value) || '#ffffff');
|
||||
let shown = 0;
|
||||
for (const card of cards()) {
|
||||
const label = card.querySelector('[data-field-label]').value.trim();
|
||||
if (!label) continue;
|
||||
shown++;
|
||||
const type = card.querySelector('[data-field-type]').value;
|
||||
const help = card.querySelector('[data-field-help]').value.trim();
|
||||
if (type === 'step') {
|
||||
// Ein Schritt ist eine Zwischenüberschrift in der Vorschau, mit seiner Einleitung.
|
||||
const step = el('div', 'form-preview-step');
|
||||
step.append(el('span', 'form-preview-step-title', label));
|
||||
if (help) step.append(el('span', 'form-preview-help', help));
|
||||
preview.append(step);
|
||||
continue;
|
||||
}
|
||||
const required = type === 'consent' || card.querySelector('[data-field-required]').checked;
|
||||
const showIf = card.querySelector('[data-field-showif]').value.trim();
|
||||
const field = el('div', `form-preview-field${showIf ? ' form-preview-conditional' : ''}`);
|
||||
const options = () => card.querySelector('[data-field-options-input]').value.split(',').map(option => option.trim()).filter(Boolean);
|
||||
if (type === 'consent') {
|
||||
const check = el('span', 'form-preview-check');
|
||||
check.append(el('span', 'form-preview-box'), document.createTextNode(`${label} *`));
|
||||
field.append(check);
|
||||
} else if (type === 'radio' || type === 'checkboxes') {
|
||||
field.append(el('span', 'form-preview-label', label + (required ? ' *' : '')));
|
||||
for (const option of options()) {
|
||||
const check = el('span', 'form-preview-check');
|
||||
check.append(el('span', `form-preview-box${type === 'radio' ? ' form-preview-radio' : ''}`), document.createTextNode(option));
|
||||
field.append(check);
|
||||
}
|
||||
} else {
|
||||
field.append(el('span', 'form-preview-label', label + (required ? ' *' : '')));
|
||||
const unit = type === 'number' ? card.querySelector('[data-field-unit]').value.trim() : '';
|
||||
field.append(el('span', `form-preview-input${type === 'textarea' ? ' form-preview-area' : ''}${type === 'select' ? ' form-preview-select' : ''}${type === 'number' ? ' form-preview-number' : ''}`, type === 'select' ? '— bitte wählen —' : unit));
|
||||
}
|
||||
if (help) field.append(el('span', 'form-preview-help', help));
|
||||
if (showIf) field.append(el('span', 'form-preview-help', `Nur wenn ${showIf} = ${card.querySelector('[data-field-showvalues]').value.split(',').map(v => v.trim()).filter(Boolean).join(' oder ') || '…'}`));
|
||||
preview.append(field);
|
||||
}
|
||||
if (!shown) preview.append(el('p', 'form-preview-empty', 'Noch kein Feld mit Beschriftung.'));
|
||||
const values = { ...schema.buttons };
|
||||
const fill = colorInputs.buttonColor.value, ink = colorInputs.buttonInk.value;
|
||||
if (fill) { values.backgroundColor = fill; values.hoverBackgroundColor = darker(fill); }
|
||||
if (ink) { values.color = ink; values.hoverColor = ink; }
|
||||
preview.append(buttonSample(buttonLabel.value.trim() || 'Absenden', values, schema));
|
||||
// Der Kontrast der Buttonschrift auf der Buttonfarbe, nach WCAG: unter 4,5:1 ist der Text
|
||||
// fuer viele nicht mehr lesbar - das soll man sehen, bevor es auf der Website steht.
|
||||
const ratio = contrast(hex(values.backgroundColor), hex(values.color));
|
||||
note.hidden = ratio === null;
|
||||
if (ratio !== null) {
|
||||
note.className = `contrast-note ${ratio >= 4.5 ? 'contrast-ok' : ratio >= 3 ? 'contrast-warn' : 'contrast-bad'}`;
|
||||
note.textContent = `Buttonschrift auf Buttonfarbe: Kontrast ${ratio.toFixed(1).replace('.', ',')}:1 – ${ratio >= 4.5 ? 'gut lesbar.' : ratio >= 3 ? 'nur für große Schrift ausreichend, für den Button zu wenig.' : 'zu wenig, der Text ist kaum lesbar.'}`;
|
||||
}
|
||||
};
|
||||
const note = el('p', 'contrast-note'); note.hidden = true; note.setAttribute('role', 'status');
|
||||
preview.after(note);
|
||||
|
||||
// Statt Kennungen zu tippen: je Option ein Bild aus der Mediathek, gewählt im Dialog mit
|
||||
// Vorschau und Hochladen. Das Textfeld bleibt das gespeicherte Original und wird aus der
|
||||
// Auswahl geschrieben; ohne Skript tippt man hinein.
|
||||
const bildVon = new Map(bilder.map(bild => [bild.id, bild]));
|
||||
const renderImagePickers = card => {
|
||||
const area = card.querySelector('[data-field-images-input]');
|
||||
const options = card.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean);
|
||||
let box = card.querySelector('[data-image-pickers]');
|
||||
if (!box) {
|
||||
box = el('div', 'field-image-pickers'); box.dataset.imagePickers = '';
|
||||
area.hidden = true; area.after(box);
|
||||
}
|
||||
if (box.dataset.signature === options.join('|')) return;
|
||||
const current = Object.fromEntries(area.value.split('\n').filter(l => l.includes('=')).map(l => l.split('=').map(x => x.trim())));
|
||||
box.dataset.signature = options.join('|');
|
||||
box.replaceChildren();
|
||||
const write = () => {
|
||||
const map = {};
|
||||
for (const row of box.querySelectorAll('[data-option]')) if (row.dataset.media) map[row.dataset.option] = row.dataset.media;
|
||||
area.value = Object.entries(map).map(([o, id]) => `${o} = ${id}`).join('\n');
|
||||
renderPreview();
|
||||
};
|
||||
for (const option of options) {
|
||||
const row = el('div', 'field-image-picker'); row.dataset.option = option;
|
||||
const thumb = document.createElement('img'); thumb.className = 'field-image-picker-thumb'; thumb.alt = ''; thumb.width = 64; thumb.height = 48;
|
||||
const name = el('span', 'field-image-picker-name');
|
||||
const choose = el('button', 'secondary', ''); choose.type = 'button';
|
||||
const remove = el('button', 'text-button danger-text', 'Entfernen'); remove.type = 'button';
|
||||
remove.setAttribute('aria-label', `Bild für „${option}“ entfernen`);
|
||||
const show = id => {
|
||||
row.dataset.media = id || '';
|
||||
const bild = id ? bildVon.get(id) : null;
|
||||
thumb.hidden = !bild; if (bild) thumb.src = bild.src; else thumb.removeAttribute('src');
|
||||
name.textContent = id ? (bild?.title || 'Bild gewählt') : 'Kein Bild';
|
||||
choose.textContent = id ? 'Anderes Bild' : 'Bild wählen';
|
||||
choose.setAttribute('aria-label', `${id ? 'Anderes Bild' : 'Bild'} für „${option}“ wählen`);
|
||||
remove.hidden = !id;
|
||||
};
|
||||
choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: ([item]) => {
|
||||
if (!item) return;
|
||||
bildVon.set(item.id, { id: item.id, title: item.title, src: item.variants[0].src });
|
||||
show(item.id); write();
|
||||
} }));
|
||||
remove.addEventListener('click', () => { show(''); write(); choose.focus(); });
|
||||
show(current[option] || '');
|
||||
row.append(el('span', 'field-image-picker-option', option), thumb, name, choose, remove); box.append(row);
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
* Auswahl statt Feldnamen: die Bedingung eines Feldes nennt ein Auswahlfeld weiter oben und
|
||||
* dessen Optionen, die Antwortadresse ein E-Mail-Feld. Die Textfelder bleiben das
|
||||
* gespeicherte Original, versteckt, und werden aus der Auswahl geschrieben.
|
||||
*/
|
||||
const fieldsBefore = card => {
|
||||
const list = [];
|
||||
for (const other of cards()) {
|
||||
if (other === card) break;
|
||||
const name = other.querySelector('[data-field-name]').value.trim();
|
||||
const type = other.querySelector('[data-field-type]').value;
|
||||
if (name && ['select', 'radio', 'checkboxes'].includes(type)) {
|
||||
list.push({ name, label: other.querySelector('[data-field-label]').value.trim() || name,
|
||||
options: other.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean) });
|
||||
}
|
||||
}
|
||||
return list;
|
||||
};
|
||||
const renderCondition = card => {
|
||||
const fieldInput = card.querySelector('[data-field-showif]');
|
||||
const valuesInput = card.querySelector('[data-field-showvalues]');
|
||||
const candidates = fieldsBefore(card);
|
||||
const signature = JSON.stringify([candidates, fieldInput.value, valuesInput.value]);
|
||||
let box = card.querySelector('[data-condition-picker]');
|
||||
if (!box) {
|
||||
box = el('div', 'field-condition-picker'); box.dataset.conditionPicker = '';
|
||||
fieldInput.closest('div').hidden = true; valuesInput.closest('div').hidden = true;
|
||||
fieldInput.closest('[data-field-condition]').prepend(box);
|
||||
const help = box.parentElement.querySelector(':scope > .field-help');
|
||||
if (help) help.textContent = 'Zur Wahl stehen Auswahlfelder weiter oben. Bei einem Schritt gilt die Bedingung für alle seine Felder.';
|
||||
}
|
||||
if (box.dataset.signature === signature) return;
|
||||
box.dataset.signature = signature;
|
||||
const focused = box.contains(document.activeElement) ? document.activeElement.dataset.key : null;
|
||||
box.replaceChildren();
|
||||
const id = `${fieldInput.id}-select`;
|
||||
const label = el('label', '', 'Nur zeigen, wenn'); label.htmlFor = id;
|
||||
const select = document.createElement('select'); select.id = id; select.dataset.key = 'field';
|
||||
select.append(new Option('Immer zeigen', ''));
|
||||
for (const candidate of candidates) select.append(new Option(candidate.label, candidate.name));
|
||||
if (fieldInput.value && !candidates.some(c => c.name === fieldInput.value)) select.append(new Option(`${fieldInput.value} (nicht mehr weiter oben)`, fieldInput.value));
|
||||
select.value = fieldInput.value;
|
||||
select.addEventListener('change', () => { fieldInput.value = select.value; valuesInput.value = ''; refresh(); });
|
||||
box.append(label, select);
|
||||
const chosen = candidates.find(c => c.name === fieldInput.value);
|
||||
if (chosen) {
|
||||
const set = document.createElement('fieldset'); set.className = 'field-condition-values';
|
||||
set.append(el('legend', '', '… eine dieser Antworten gewählt ist'));
|
||||
const current = valuesInput.value.split(',').map(v => v.trim()).filter(Boolean);
|
||||
chosen.options.forEach((option, index) => {
|
||||
const check = el('label', 'check-label');
|
||||
const box2 = document.createElement('input'); box2.type = 'checkbox'; box2.value = option; box2.checked = current.includes(option);
|
||||
box2.dataset.key = `value-${index}`;
|
||||
box2.addEventListener('change', () => {
|
||||
valuesInput.value = [...set.querySelectorAll('input:checked')].map(input => input.value).join(', ');
|
||||
refresh();
|
||||
});
|
||||
check.append(box2, document.createTextNode(` ${option}`)); set.append(check);
|
||||
});
|
||||
box.append(set);
|
||||
}
|
||||
if (focused) box.querySelector(`[data-key="${focused}"]`)?.focus();
|
||||
};
|
||||
const reply = form.querySelector('[name="replyToField"]');
|
||||
const renderReply = () => {
|
||||
if (!reply) return;
|
||||
const emails = cards().map(card => ({ name: card.querySelector('[data-field-name]').value.trim(), label: card.querySelector('[data-field-label]').value.trim(), type: card.querySelector('[data-field-type]').value }))
|
||||
.filter(field => field.name && field.type === 'email');
|
||||
let select = form.querySelector('[data-reply-picker]');
|
||||
if (!select) {
|
||||
select = document.createElement('select'); select.dataset.replyPicker = ''; select.id = reply.id;
|
||||
reply.id += '-value'; reply.type = 'hidden'; reply.after(select);
|
||||
select.addEventListener('change', () => { reply.value = select.value; });
|
||||
}
|
||||
const signature = JSON.stringify([emails, reply.value]);
|
||||
if (select.dataset.signature === signature) return;
|
||||
select.dataset.signature = signature;
|
||||
select.replaceChildren(new Option('Keine – Antworten gehen an den Absender der E-Mail', ''));
|
||||
for (const field of emails) select.append(new Option(field.label || field.name, field.name));
|
||||
if (reply.value && !emails.some(field => field.name === reply.value)) select.append(new Option(`${reply.value} (kein E-Mail-Feld mehr)`, reply.value));
|
||||
select.value = reply.value;
|
||||
const help = reply.parentElement.querySelector('.field-help');
|
||||
if (help) help.textContent = 'Ein E-Mail-Feld des Formulars. Dann lässt sich auf die Nachricht direkt antworten.';
|
||||
};
|
||||
|
||||
// Eine Feldkarte ist eine Zeile; die Details öffnet man bei Bedarf. Darunter steht kurz, was
|
||||
// in ihnen eingestellt ist, damit man nicht jede Karte aufklappen muss.
|
||||
const setOpen = (card, open) => {
|
||||
card.querySelector('[data-field-body]').hidden = !open;
|
||||
const toggle = card.querySelector('[data-field-toggle]');
|
||||
toggle.setAttribute('aria-expanded', String(open));
|
||||
card.classList.toggle('is-open', open);
|
||||
};
|
||||
const meta = card => {
|
||||
const type = card.querySelector('[data-field-type]').value;
|
||||
const parts = [card.querySelector('[data-field-name]').value.trim()];
|
||||
const options = card.querySelector('[data-field-options-input]').value.split(',').map(o => o.trim()).filter(Boolean);
|
||||
if (['select', 'radio', 'checkboxes'].includes(type)) parts.push(options.length ? options.join(', ') : 'noch keine Optionen');
|
||||
if (card.querySelector('[data-field-help]').value.trim()) parts.push('Hilfetext');
|
||||
if (card.querySelector('[data-field-showif]').value.trim()) parts.push('mit Bedingung');
|
||||
for (const translation of card.querySelectorAll('[data-field-translation]')) {
|
||||
if ([...translation.querySelectorAll('input')].some(input => input.value.trim())) parts.push(`${translation.dataset.fieldTranslation.toUpperCase()} ✓`);
|
||||
}
|
||||
return parts.filter(Boolean).join(' · ');
|
||||
};
|
||||
|
||||
const refresh = () => {
|
||||
cards().forEach((card, index) => {
|
||||
card.querySelector('[data-field-index]').textContent = String(index + 1);
|
||||
const type = card.querySelector('[data-field-type]').value;
|
||||
const line = card.querySelector('[data-field-meta]');
|
||||
line.textContent = meta(card); line.hidden = !line.textContent || card.classList.contains('is-open');
|
||||
const label = card.querySelector('[data-field-label]').value.trim() || `Feld ${index + 1}`;
|
||||
card.querySelector('[data-field-toggle]').setAttribute('aria-label', `Details zu „${label}“`);
|
||||
card.querySelector('[data-field-options]').hidden = !['select', 'radio', 'checkboxes'].includes(type);
|
||||
card.querySelector('[data-field-images]').hidden = !['radio', 'checkboxes'].includes(type);
|
||||
if (['radio', 'checkboxes'].includes(type)) renderImagePickers(card);
|
||||
renderCondition(card);
|
||||
card.querySelector('[data-field-number]').hidden = type !== 'number';
|
||||
const remove = card.querySelector('[data-remove-field]');
|
||||
remove.hidden = false; remove.setAttribute('aria-label', `Feld ${index + 1} entfernen`);
|
||||
const required = card.querySelector('[data-field-required]');
|
||||
// Eine Einwilligung ist immer Pflicht - das Kästchen sagt es, statt es zu verstecken.
|
||||
if (type === 'consent') { required.checked = true; required.disabled = true; }
|
||||
else if (type === 'step') { required.checked = false; required.disabled = true; }
|
||||
else required.disabled = false;
|
||||
});
|
||||
renderReply();
|
||||
renderPreview();
|
||||
};
|
||||
|
||||
// Die leere Karte für ein neues Feld braucht es nur, solange es kein Feld gibt: mit Skript
|
||||
// kommt jedes weitere über den Knopf.
|
||||
const empty = cards().filter(card => card.classList.contains('field-card-new'));
|
||||
if (cards().length > empty.length) empty.forEach(card => card.remove());
|
||||
add.hidden = false;
|
||||
for (const card of cards()) {
|
||||
card.querySelector('[data-field-toggle]').hidden = false;
|
||||
setOpen(card, card.classList.contains('field-card-new'));
|
||||
}
|
||||
add.addEventListener('click', () => {
|
||||
list.insertAdjacentHTML('beforeend', template.innerHTML.replaceAll('__n__', String(next++)));
|
||||
const card = list.lastElementChild; card.classList.remove('field-card-new');
|
||||
card.querySelector('[data-field-toggle]').hidden = false; setOpen(card, true);
|
||||
refresh(); card.querySelector('[data-field-label]').focus();
|
||||
});
|
||||
list.addEventListener('click', event => {
|
||||
const toggle = event.target.closest('[data-field-toggle]');
|
||||
if (toggle) { const card = toggle.closest('[data-field]'); setOpen(card, !card.classList.contains('is-open')); refresh(); return; }
|
||||
const button = event.target.closest('[data-remove-field]');
|
||||
if (!button) return;
|
||||
button.closest('[data-field]').remove(); refresh(); add.focus();
|
||||
});
|
||||
list.addEventListener('input', event => {
|
||||
const card = event.target.closest('[data-field]');
|
||||
if (event.target.matches('[data-field-label]')) {
|
||||
// Der technische Name folgt der Beschriftung, bis jemand ihn selbst schreibt.
|
||||
const name = card.querySelector('[data-field-name]');
|
||||
if (!name.dataset.touched) name.value = slug(event.target.value);
|
||||
}
|
||||
if (event.target.matches('[data-field-name]')) event.target.dataset.touched = event.target.value ? '1' : '';
|
||||
refresh();
|
||||
});
|
||||
list.addEventListener('change', refresh);
|
||||
for (const card of cards()) {
|
||||
const name = card.querySelector('[data-field-name]');
|
||||
if (name.value) name.dataset.touched = '1';
|
||||
}
|
||||
|
||||
for (const input of form.querySelectorAll('[data-form-color]')) {
|
||||
const key = input.dataset.formColor, label = form.querySelector(`label[for="${input.id}"]`).textContent;
|
||||
const id = input.id; input.type = 'hidden'; input.id += '-value';
|
||||
colorInputs[key] = input;
|
||||
const control = createColorControl({
|
||||
id, label, value: input.value, inheritedValue: defaults[key], colors: schema.colors,
|
||||
onChange: value => { input.value = value; renderPreview(); }, onComplete: () => {},
|
||||
});
|
||||
control.querySelector('.color-inherit').textContent = 'Wie die Website';
|
||||
input.after(control);
|
||||
}
|
||||
buttonLabel.addEventListener('input', renderPreview);
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Formulare mit Schritten, Bedingungen und Zusammenfassung.
|
||||
*
|
||||
* Ohne dieses Skript steht das Formular vollständig da: jeder Schritt ein Abschnitt, jedes Feld
|
||||
* sichtbar, der Server prüft beim Absenden, welche Felder galten. Das Skript ändert nur die
|
||||
* Darstellung: ein Schritt nach dem anderen, Felder hinter einer Bedingung nur bei passender
|
||||
* Antwort, und vor dem Absenden eine Zusammenfassung der Eingaben. Es wird nur auf Seiten
|
||||
* geladen, deren Formular eines davon braucht.
|
||||
*/
|
||||
(() => {
|
||||
const el = (tag, className, text) => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
|
||||
const setup = form => {
|
||||
// Die Texte des Skripts. Auf einer übersetzten Seite bringt das Formular sie mit (ADR 111).
|
||||
let texts = {};
|
||||
try { texts = JSON.parse(form.dataset.texts || '{}'); } catch { texts = {}; }
|
||||
const t = (key, fallback) => texts[key] || fallback;
|
||||
const rules = [...form.querySelectorAll('[data-show-if]')];
|
||||
const steps = [...form.querySelectorAll('[data-step]')];
|
||||
const submit = form.querySelector('.cms-button[type="submit"]');
|
||||
const wantsSummary = form.hasAttribute('data-summary') && steps.length > 0 && submit;
|
||||
if (!rules.length && !steps.length) return;
|
||||
|
||||
// Die gewählten Werte eines Feldes - nur, solange es gilt. Ein Feld in einem ausgeblendeten
|
||||
// Schritt zählt nicht, auch wenn dort noch etwas angekreuzt ist.
|
||||
const valuesOf = name => [...form.querySelectorAll(`[name="${name}"], [name="${name}[]"]`)]
|
||||
.filter(input => !input.closest('[data-inactive]'))
|
||||
.filter(input => input.type === 'checkbox' || input.type === 'radio' ? input.checked : true)
|
||||
.map(input => input.value)
|
||||
.filter(value => value !== '');
|
||||
|
||||
// Bedingungen in Dokumentreihenfolge: ein Feld hängt nur von früheren Feldern ab, also steht
|
||||
// deren Zustand schon fest, wenn es an der Reihe ist.
|
||||
const apply = () => {
|
||||
for (const rule of rules) {
|
||||
const wanted = rule.dataset.showValues.split('|');
|
||||
const have = valuesOf(rule.dataset.showIf);
|
||||
rule.toggleAttribute('data-inactive', !wanted.some(value => have.includes(value)));
|
||||
}
|
||||
// Was nicht gilt, wird nicht geprüft und nicht gesendet: deaktiviert, nicht nur versteckt.
|
||||
for (const input of form.querySelectorAll('input, select, textarea')) {
|
||||
if (input.type === 'hidden') continue;
|
||||
input.disabled = input.closest('[data-inactive]') !== null;
|
||||
}
|
||||
};
|
||||
form.addEventListener('change', () => { apply(); if (steps.length) refreshNav(); });
|
||||
apply();
|
||||
|
||||
if (!steps.length) return;
|
||||
|
||||
// Die Zusammenfassung ist ein eigener, letzter Schritt - erst hier steht der Absendeknopf.
|
||||
let summary = null;
|
||||
if (wantsSummary) {
|
||||
summary = el('div', 'form-step form-summary');
|
||||
summary.setAttribute('data-step', '');
|
||||
const title = el('p', 'form-step-title', t('summary', 'Zusammenfassung'));
|
||||
title.tabIndex = -1;
|
||||
summary.append(title, el('p', 'form-step-intro', t('summaryIntro', 'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.')));
|
||||
summary.append(el('div', 'form-summary-body'));
|
||||
steps[steps.length - 1].after(summary);
|
||||
steps.push(summary);
|
||||
}
|
||||
|
||||
const progress = el('ol', 'form-progress');
|
||||
progress.setAttribute('aria-label', t('steps', 'Schritte des Formulars'));
|
||||
steps.forEach((step, index) => {
|
||||
const item = el('li', 'form-progress-step');
|
||||
item.append(el('span', 'form-progress-number', String(index + 1)), el('span', 'form-progress-title', step.querySelector('.form-step-title')?.textContent.trim() || `${t('step', 'Schritt')} ${index + 1}`));
|
||||
progress.append(item);
|
||||
});
|
||||
form.insertBefore(progress, steps[0]);
|
||||
|
||||
const nav = el('div', 'form-step-nav');
|
||||
const back = el('button', 'button cms-button form-step-back', t('back', 'Zurück'));
|
||||
back.type = 'button';
|
||||
const next = el('button', 'button cms-button form-step-next', t('next', 'Weiter'));
|
||||
next.type = 'button';
|
||||
nav.append(back, next);
|
||||
(submit || form).before(nav);
|
||||
if (submit) nav.append(submit);
|
||||
|
||||
const visible = () => steps.map((step, index) => step.hasAttribute('data-inactive') ? -1 : index).filter(index => index >= 0);
|
||||
let current = 0;
|
||||
|
||||
const labelOf = field => field.dataset.label || '';
|
||||
const valueOf = field => {
|
||||
const inputs = [...field.querySelectorAll('input, select, textarea')].filter(input => !input.disabled);
|
||||
const picked = inputs.filter(input => input.type === 'checkbox' || input.type === 'radio' ? input.checked : input.value.trim() !== '');
|
||||
if (!picked.length) return '';
|
||||
const unit = field.querySelector('.form-unit')?.textContent || '';
|
||||
return picked.map(input => {
|
||||
if (input.type === 'checkbox' && input.value === 'ja' && field.querySelector('.check-row label')) return t('yes', 'Ja');
|
||||
if (input.tagName === 'SELECT') return input.selectedOptions[0]?.textContent || input.value;
|
||||
// Angezeigt wird die Beschriftung der Option - auf einer übersetzten Seite die übersetzte.
|
||||
if (input.type === 'checkbox' || input.type === 'radio') return (input.labels?.[0]?.querySelector('.form-choice-text') ?? input.labels?.[0])?.textContent.trim() || input.value;
|
||||
return input.value + (unit ? ` ${unit}` : '');
|
||||
}).join(', ');
|
||||
};
|
||||
|
||||
const renderSummary = () => {
|
||||
const body = summary.querySelector('.form-summary-body');
|
||||
body.replaceChildren();
|
||||
steps.forEach((step, index) => {
|
||||
if (step === summary || step.hasAttribute('data-inactive')) return;
|
||||
const block = el('section', 'form-summary-step');
|
||||
const head = el('div', 'form-summary-head');
|
||||
head.append(el('h4', 'form-summary-title', step.querySelector('.form-step-title')?.textContent.trim() || `${t('step', 'Schritt')} ${index + 1}`));
|
||||
const change = el('button', 'form-summary-change', t('change', 'Ändern'));
|
||||
change.type = 'button';
|
||||
change.setAttribute('aria-label', `${head.firstChild.textContent} ändern`);
|
||||
change.addEventListener('click', () => show(index));
|
||||
head.append(change);
|
||||
block.append(head);
|
||||
const list = el('dl', 'form-summary-list');
|
||||
for (const field of step.querySelectorAll('[data-field]')) {
|
||||
if (field.closest('[data-inactive]')) continue;
|
||||
const value = valueOf(field);
|
||||
list.append(el('dt', '', labelOf(field)), el('dd', value ? '' : 'form-summary-empty', value || '—'));
|
||||
}
|
||||
block.append(list);
|
||||
body.append(block);
|
||||
});
|
||||
};
|
||||
|
||||
const refreshNav = () => {
|
||||
const order = visible();
|
||||
const position = order.indexOf(current);
|
||||
const last = position === order.length - 1;
|
||||
back.hidden = position <= 0;
|
||||
next.hidden = last;
|
||||
if (submit) submit.hidden = !last;
|
||||
progress.querySelectorAll('li').forEach((item, index) => {
|
||||
item.hidden = steps[index].hasAttribute('data-inactive');
|
||||
item.classList.toggle('is-done', order.indexOf(index) < position);
|
||||
if (index === current) item.setAttribute('aria-current', 'step'); else item.removeAttribute('aria-current');
|
||||
});
|
||||
};
|
||||
|
||||
const show = (index, focus = true) => {
|
||||
current = index;
|
||||
steps.forEach((step, i) => { step.hidden = i !== index; });
|
||||
if (summary && steps[index] === summary) renderSummary();
|
||||
refreshNav();
|
||||
if (focus) {
|
||||
steps[index].querySelector('.form-step-title')?.focus({ preventScroll: true });
|
||||
if (progress.getBoundingClientRect().top < 0) progress.scrollIntoView({ block: 'start' });
|
||||
}
|
||||
};
|
||||
|
||||
// Erst der Schritt selbst prüft seine Felder: der Browser meldet das erste ungültige Feld,
|
||||
// eine Pflicht-Mehrfachauswahl ohne Kreuz meldet die Gruppe.
|
||||
const valid = step => {
|
||||
for (const group of step.querySelectorAll('[data-required]')) {
|
||||
if (group.closest('[data-inactive]')) continue;
|
||||
const note = group.querySelector('.field-error[data-live]');
|
||||
if (!group.querySelector('input:checked')) {
|
||||
if (!note) group.append(Object.assign(el('p', 'field-error', t('chooseOne', 'Bitte wähle mindestens eine Option.')), { dataset: { live: '' } }));
|
||||
else note.hidden = false;
|
||||
group.classList.add('has-error');
|
||||
group.querySelector('input')?.focus();
|
||||
return false;
|
||||
}
|
||||
if (note) note.remove();
|
||||
group.classList.remove('has-error');
|
||||
}
|
||||
for (const input of step.querySelectorAll('input, select, textarea')) {
|
||||
if (input.disabled || input.checkValidity()) continue;
|
||||
input.reportValidity();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
next.addEventListener('click', () => {
|
||||
if (!valid(steps[current])) return;
|
||||
const order = visible();
|
||||
const following = order[order.indexOf(current) + 1];
|
||||
if (following !== undefined) show(following);
|
||||
});
|
||||
back.addEventListener('click', () => {
|
||||
const order = visible();
|
||||
const previous = order[order.indexOf(current) - 1];
|
||||
if (previous !== undefined) show(previous);
|
||||
});
|
||||
// Enter in einem Feld geht zum nächsten Schritt, statt das Formular vorzeitig abzuschicken.
|
||||
form.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Enter' || event.target.tagName === 'TEXTAREA' || event.target.tagName === 'BUTTON') return;
|
||||
if (!next.hidden) { event.preventDefault(); next.click(); }
|
||||
});
|
||||
|
||||
// Nach einer Ablehnung durch den Server: der erste Schritt mit einem Fehler, sonst der erste.
|
||||
const failed = steps.findIndex(step => step.querySelector('.has-error'));
|
||||
const order = visible();
|
||||
show(failed >= 0 ? failed : (order[0] ?? 0), false);
|
||||
form.classList.add('form-steps-ready');
|
||||
};
|
||||
|
||||
document.querySelectorAll('form.block-form').forEach(setup);
|
||||
})();
|
||||
@@ -0,0 +1,280 @@
|
||||
// Der Symbolsatz: Zeichnung, deutscher Name und Kategorie. Die Website zeichnet dieselben Pfade aus
|
||||
// templates/blocks/icon.html.twig; ein Test hält Twig, diese Datei und DocumentValidator::ICONS gleich.
|
||||
// Alle Symbole sind Linien auf einem 24er-Raster (stroke, keine Füllung) und erben die Textfarbe.
|
||||
export const ICON_PATHS = {
|
||||
// Pfeile und Navigation
|
||||
'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9',
|
||||
'arrow-right': 'M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5',
|
||||
'arrow-left': 'M20 12H4.5M11 5.5 4.5 12 11 18.5',
|
||||
'arrow-up': 'M12 20V4.5m-6.5 6.5L12 4.5l6.5 6.5',
|
||||
'arrow-down': 'M12 4v15.5m-6.5-6.5 6.5 6.5 6.5-6.5',
|
||||
'chevron-right': 'm9 5.5 6.5 6.5L9 18.5',
|
||||
'chevron-left': 'M15 5.5 8.5 12l6.5 6.5',
|
||||
'chevron-up': 'm5.5 15 6.5-6.5 6.5 6.5',
|
||||
'chevron-down': 'm5.5 9 6.5 6.5L18.5 9',
|
||||
'arrow-circle': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9h8m-3-3 3 3-3 3',
|
||||
external: 'M14 4h6v6m0-6-9 9M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5',
|
||||
refresh: 'M20 12a8 8 0 1 1-2.3-5.6M20 4v5h-5',
|
||||
menu: 'M4 7h16M4 12h16M4 17h16',
|
||||
close: 'M6 6l12 12M18 6 6 18',
|
||||
plus: 'M12 5v14M5 12h14',
|
||||
minus: 'M5 12h14',
|
||||
// Kontakt und Ort
|
||||
phone: 'M6 3h4l2 5-2.5 1.5a12 12 0 0 0 5 5L16 12l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 4 5a2 2 0 0 1 2-2Z',
|
||||
app: 'M7 2h10a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 17h2',
|
||||
mail: 'M3 6h18v12H3Zm0 1 9 7 9-7',
|
||||
chat: 'M4 5h16v11H9.5L5 19.5V16H4Z',
|
||||
send: 'M21 3 10 14M21 3l-6.5 18-4.5-7-7-4.5Z',
|
||||
calendar: 'M4 6h16v14H4Zm0 5h16M8 3.5v4m8-4v4',
|
||||
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 4v5.5l3.5 2',
|
||||
pin: 'M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11Zm0-8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z',
|
||||
map: 'M9 4 3 6.5V20l6-2.5 6 2.5 6-2.5V4l-6 2.5ZM9 4v13.5m6-11V20',
|
||||
navigation: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm3.5 5.5-2 5-5 2 2-5Z',
|
||||
web: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 0c2.5 2.4 3.8 5.4 3.8 9s-1.3 6.6-3.8 9m0-18C9.5 5.4 8.2 8.4 8.2 12s1.3 6.6 3.8 9M3.2 9h17.6M3.2 15h17.6',
|
||||
wifi: 'M3 9.5a13 13 0 0 1 18 0M6 13a8 8 0 0 1 12 0m-9 3.5a3.5 3.5 0 0 1 6 0M12 19.5h.01',
|
||||
support: 'M12 3a7 7 0 0 0-7 7v5m14-5a7 7 0 0 0-7-7m7 7v5m-14 0a2 2 0 0 0 2 2h1v-5H5Zm14 0a2 2 0 0 1-2 2h-1v-5h3Z',
|
||||
// Handel und Geld
|
||||
shop: 'M4 7h16l-1.4 11.2a2 2 0 0 1-2 1.8H7.4a2 2 0 0 1-2-1.8Zm4 0a4 4 0 0 1 8 0',
|
||||
cart: 'M3 4h2.5l2.2 10.5h10.6L20.5 7H6.3M9.5 18a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Zm7.5 0a1.2 1.2 0 1 0 0 2.4 1.2 1.2 0 0 0 0-2.4Z',
|
||||
tag: 'M3.5 12.5v-8a1 1 0 0 1 1-1h8l8 8a1.4 1.4 0 0 1 0 2l-7 7a1.4 1.4 0 0 1-2 0ZM8.5 8h.01',
|
||||
euro: 'M17.5 6.5A6.5 6.5 0 1 0 17.5 17.5M4 10h9m-9 4h9',
|
||||
'credit-card': 'M3 6h18v12H3Zm0 4h18M6.5 15h3',
|
||||
gift: 'M4 9h16v4H4Zm1.5 4v7h13v-7M12 9v11m0-11S10.5 4 8 4.5 7.5 9 12 9Zm0 0s1.5-5 4-4.5S16.5 9 12 9Z',
|
||||
percent: 'M19 5 5 19M7 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm10 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
|
||||
truck: 'M2.5 6h11v10.5h-11Zm11 3.5h4l3 3.5v3.5h-7ZM6.5 17a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6Zm10.5 0a1.8 1.8 0 1 0 0 3.6 1.8 1.8 0 0 0 0-3.6Z',
|
||||
package: 'M3.5 7.5 12 3l8.5 4.5v9L12 21l-8.5-4.5Zm0 0L12 12l8.5-4.5M12 12v9M7.8 5.2l8.5 4.6',
|
||||
briefcase: 'M3.5 7.5h17v12h-17Zm5-3h7v3h-7Zm-5 8h17',
|
||||
// Dateien und Medien
|
||||
document: 'M6 3h8l4 4v14H6Zm8 0v4h4M9 12h6m-6 4h6',
|
||||
download: 'M12 3.5v12m-5-5 5 5 5-5M4.5 20h15',
|
||||
upload: 'M12 15.5V4m-5 5 5-5 5 5M4.5 20h15',
|
||||
print: 'M7 9V3.5h10V9M7 17H4.5V9h15v8H17M7 14h10v6.5H7Z',
|
||||
link: 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.2 1.2M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.2-1.2',
|
||||
share: 'M18 3.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM6 9.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5Zm12 6a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM8.2 10.8l7.6-4.1m-7.6 6.5 7.6 4.1',
|
||||
image: 'M4 4h16v16H4Zm0 12 4.5-4.5 3.5 3.5 2.5-2.5L20 18M15 8.5h.01',
|
||||
camera: 'M3 7.5h4L8.5 5h7L17 7.5h4V19H3Zm9 2.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z',
|
||||
video: 'M3 6.5h12v11H3Zm12 4 6-3.5v10l-6-3.5',
|
||||
play: 'M8 5.5v13l10-6.5Z',
|
||||
pause: 'M8 5v14m8-14v14',
|
||||
music: 'M10 18V5.5L20 3v12.5M10 9.5 20 7M10 18a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0ZM20 15.5a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0Z',
|
||||
mic: 'M12 3a3 3 0 0 0-3 3v5.5a3 3 0 0 0 6 0V6a3 3 0 0 0-3-3ZM5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21',
|
||||
book: 'M5 4.5A1.5 1.5 0 0 1 6.5 3H19v15H6.5A1.5 1.5 0 0 0 5 19.5Zm0 15A1.5 1.5 0 0 0 6.5 21H19v-3',
|
||||
// Personen und Sicherheit
|
||||
user: 'M12 11.5a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM4.5 20.5c0-3.9 3.4-6 7.5-6s7.5 2.1 7.5 6',
|
||||
people: 'M9.5 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM2.5 20v-1c0-3.3 3-5 7-5s7 1.7 7 5v1M16.5 5.3a3.2 3.2 0 0 1 0 6.4M18 14.4c2.1.7 3.5 2.1 3.5 4.1V20',
|
||||
login: 'M14 4h5a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-5M3.5 12h11m-4-4 4 4-4 4',
|
||||
lock: 'M5 10.5h14V21H5Zm3 0V7a4 4 0 0 1 8 0v3.5M12 14.5v3',
|
||||
key: 'M7.5 11.5a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm2.8 1.2L19 4m-3 3 2.5 2.5M13 10l2 2',
|
||||
shield: 'M12 3 5 6v6c0 4.4 2.9 7.9 7 9 4.1-1.1 7-4.6 7-9V6Zm-2.5 9 2 2 4-4.5',
|
||||
eye: 'M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Zm9.5-3a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z',
|
||||
'thumbs-up': 'M7 10.5V20H4v-9.5Zm0 0 3.5-7a2 2 0 0 1 2.5 2v3.5h5.2a2 2 0 0 1 2 2.4l-1.4 7a2 2 0 0 1-2 1.6H7',
|
||||
award: 'M12 3a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11Zm-3.5 10L7 21l5-2.5 5 2.5-1.5-8',
|
||||
graduation: 'M2.5 9 12 4.5 21.5 9 12 13.5Zm4 2.3V16c0 1.5 2.5 3 5.5 3s5.5-1.5 5.5-3v-4.7M21.5 9v5',
|
||||
heart: 'M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10Z',
|
||||
star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z',
|
||||
// Haus, Garten und Handwerk
|
||||
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
|
||||
building: 'M5 21V4h10v17m0-12h4v12M3 21h18M8.5 7.5h3m-3 3.5h3m-3 3.5h3',
|
||||
wrench: 'M14.5 3.5a5 5 0 0 0-4.6 6.9L3.5 16.8a1.9 1.9 0 0 0 2.7 2.7l6.4-6.4a5 5 0 0 0 6.9-4.6l-3 3-3-1-1-3Z',
|
||||
hammer: 'M14 8.5 4.5 18a1.5 1.5 0 0 0 2.1 2.1L16 10.5M11 6l3-3 3.5 1L21 7.5 18.5 10l-2-2-2.5 2.5Z',
|
||||
brush: 'M4 4h13v5H4Zm13 2.5h2.5V12h-8v3M10 15h3v6h-3Z',
|
||||
ruler: 'M3.5 16 16 3.5 20.5 8 8 20.5ZM7 13l2 2m1-5 2 2m1-5 2 2',
|
||||
leaf: 'M5 19C5 10 10 5 20 4c-1 10-6 15-15 15Zm0 0 8-8',
|
||||
tree: 'M12 21v-5m0-13-6 8h3l-4 5h14l-4-5h3Z',
|
||||
sun: 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8Zm0-5v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4',
|
||||
droplet: 'M12 3s6.5 7 6.5 11.5a6.5 6.5 0 0 1-13 0C5.5 10 12 3 12 3Z',
|
||||
flame: 'M12 21a6 6 0 0 0 6-6c0-4.5-4-6.5-4.5-11-2.5 2-5 5-5 8.5-1-.5-1.5-1.5-1.8-2.5C5.6 11.5 6 13 6 15a6 6 0 0 0 6 6Z',
|
||||
bolt: 'M13 2.5 4.5 13.5H12l-1 8 8.5-11H12Z',
|
||||
solar: 'M5 4h15l-2 9.5H3Zm-1 4.75h15M10 4 8.5 13.5M15.5 4 14 13.5M10.5 13.5V18M7 20.5h7',
|
||||
thermometer: 'M10 14.5V5a2 2 0 0 1 4 0v9.5a4 4 0 1 1-4 0Z',
|
||||
snowflake: 'M12 2.5v19M3.8 7.3l16.4 9.4M3.8 16.7l16.4-9.4M9.5 4 12 6l2.5-2m-5 16 2.5-2 2.5 2',
|
||||
car: 'M5 16.5V12l2-5h10l2 5v4.5M3 12h18v4.5H3Zm3.5 4.5V19m11-2.5V19M7 14h.01M17 14h.01',
|
||||
// Allgemein
|
||||
check: 'm4 12.5 5.5 5.5L20 6',
|
||||
'check-circle': 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm-4 9.2 2.8 2.8L16.5 9',
|
||||
info: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Zm0 8v5.5m0-9v.5',
|
||||
help: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18ZM9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6m0 3v.01',
|
||||
alert: 'M12 3.5 2.5 20h19Zm0 6V14m0 3v.01',
|
||||
search: 'M10.5 4a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Zm4.8 11.3L20 20',
|
||||
settings: 'M4 7h9m4 0h3M4 17h3m4 0h9M15 5v4M9 15v4',
|
||||
filter: 'M3.5 5h17l-6.5 8v6l-4 2v-8Z',
|
||||
grid: 'M4 4h7v7H4Zm9 0h7v7h-7ZM4 13h7v7H4Zm9 0h7v7h-7Z',
|
||||
list: 'M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01',
|
||||
sparkle: 'M12 3c.6 4.4 2.6 6.4 7 7-4.4.6-6.4 2.6-7 7-.6-4.4-2.6-6.4-7-7 4.4-.6 6.4-2.6 7-7Zm6.5 12.5c.2 1.6 1 2.3 2.5 2.5-1.5.2-2.3 1-2.5 2.5-.2-1.5-1-2.3-2.5-2.5 1.5-.2 2.3-1 2.5-2.5Z',
|
||||
coffee: 'M4 9h12v6a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5Zm12 1.5h1.5a2.5 2.5 0 0 1 0 5H16M8 3.5V6m4-2.5V6',
|
||||
utensils: 'M6 3v7a2 2 0 0 0 4 0V3M8 10v11m8.5 0V3c-2 1-3 3.5-3 6.5v4h3',
|
||||
laptop: 'M5 5h14v10H5Zm-2.5 10h19l-1 3.5h-17Z',
|
||||
hardware: 'M4 5h16v11H4Zm4 15h8m-4-4v4',
|
||||
network: 'M5 3h14v5H5Zm0 13h14v5H5Zm7-8v8M8 8v3h8V8',
|
||||
design: 'M4 20h16M6 16 18 4l3 3L9 19l-4 1Z',
|
||||
};
|
||||
|
||||
export const ICON_LABELS = {
|
||||
'arrow-up-right': 'Pfeil schräg', 'arrow-right': 'Pfeil nach rechts', 'arrow-left': 'Pfeil nach links',
|
||||
'arrow-up': 'Pfeil nach oben', 'arrow-down': 'Pfeil nach unten', 'chevron-right': 'Winkel rechts',
|
||||
'chevron-left': 'Winkel links', 'chevron-up': 'Winkel oben', 'chevron-down': 'Winkel unten',
|
||||
'arrow-circle': 'Pfeil im Kreis', external: 'Externer Link', refresh: 'Aktualisieren', menu: 'Menü',
|
||||
close: 'Schließen', plus: 'Plus', minus: 'Minus',
|
||||
phone: 'Telefon', app: 'Smartphone', mail: 'E-Mail', chat: 'Nachricht', send: 'Senden', calendar: 'Kalender',
|
||||
clock: 'Uhr', pin: 'Ort', map: 'Karte', navigation: 'Navigation', web: 'Globus', wifi: 'WLAN', support: 'Kundendienst',
|
||||
shop: 'Einkaufstasche', cart: 'Warenkorb', tag: 'Preisschild', euro: 'Euro', 'credit-card': 'Karte bezahlen',
|
||||
gift: 'Geschenk', percent: 'Prozent', truck: 'Lieferung', package: 'Paket', briefcase: 'Aktentasche',
|
||||
document: 'Dokument', download: 'Download', upload: 'Hochladen', print: 'Drucken', link: 'Link', share: 'Teilen',
|
||||
image: 'Bild', camera: 'Kamera', video: 'Video', play: 'Abspielen', pause: 'Pause', music: 'Musik',
|
||||
mic: 'Mikrofon', book: 'Buch',
|
||||
user: 'Person', people: 'Personen', login: 'Anmelden', lock: 'Schloss', key: 'Schlüssel', shield: 'Schutz',
|
||||
eye: 'Ansehen', 'thumbs-up': 'Daumen hoch', award: 'Auszeichnung', graduation: 'Ausbildung', heart: 'Herz', star: 'Stern',
|
||||
home: 'Haus', building: 'Gebäude', wrench: 'Werkzeug', hammer: 'Hammer', brush: 'Malerrolle', ruler: 'Lineal',
|
||||
leaf: 'Blatt', tree: 'Baum', sun: 'Sonne', droplet: 'Wasser', flame: 'Flamme', bolt: 'Strom', solar: 'Solar',
|
||||
thermometer: 'Temperatur', snowflake: 'Kälte', car: 'Auto',
|
||||
check: 'Häkchen', 'check-circle': 'Häkchen im Kreis', info: 'Info', help: 'Frage', alert: 'Warnung',
|
||||
search: 'Suche', settings: 'Einstellungen', filter: 'Filter', grid: 'Raster', list: 'Liste', sparkle: 'Funkeln',
|
||||
coffee: 'Kaffee', utensils: 'Besteck', laptop: 'Laptop', hardware: 'Bildschirm', network: 'Netzwerk', design: 'Stift',
|
||||
};
|
||||
|
||||
/** Die Kategorien des Auswahlfensters, in der Reihenfolge der Reiter. Jedes Symbol steht genau einmal. */
|
||||
export const ICON_GROUPS = [
|
||||
['Pfeile', ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus']],
|
||||
['Kontakt & Ort', ['phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support']],
|
||||
['Handel & Geld', ['shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase']],
|
||||
['Dateien & Medien', ['document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book']],
|
||||
['Personen & Sicherheit', ['user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star']],
|
||||
['Haus & Handwerk', ['home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car']],
|
||||
['Allgemein', ['check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design']],
|
||||
];
|
||||
|
||||
const svgNS = 'http://www.w3.org/2000/svg';
|
||||
|
||||
export function iconSvg(name) {
|
||||
const svg = document.createElementNS(svgNS, 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('aria-hidden', 'true');
|
||||
svg.setAttribute('focusable', 'false');
|
||||
// Wie auf der Website: Linien in der Textfarbe. CSS darf Größe und Strichstärke überschreiben.
|
||||
for (const [name, value] of Object.entries({ fill: 'none', stroke: 'currentColor', 'stroke-width': '1.6', 'stroke-linecap': 'round', 'stroke-linejoin': 'round' })) svg.setAttribute(name, value);
|
||||
const path = document.createElementNS(svgNS, 'path');
|
||||
path.setAttribute('d', ICON_PATHS[name] ?? '');
|
||||
svg.append(path);
|
||||
return svg;
|
||||
}
|
||||
|
||||
const element = (tag, className, text) => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
|
||||
/**
|
||||
* Das Feld im Panel: das gewählte Symbol als Vorschau mit Namen und ein Knopf, der das
|
||||
* Auswahlfenster öffnet. Die Kacheln stehen nicht im Panel - bei hundert Symbolen wäre es eine
|
||||
* lange Wand, die alles andere nach unten drückt.
|
||||
*
|
||||
* @param {{ label: string, value: string, none?: string, onChoose: (name: string) => void }} options
|
||||
*/
|
||||
export function renderIconField({ label, value, none, onChoose }) {
|
||||
const wrap = element('div', 'icon-field');
|
||||
wrap.append(element('p', 'icon-picker-label', label));
|
||||
const row = element('div', 'icon-field-row');
|
||||
const preview = element('span', 'icon-field-preview');
|
||||
preview.setAttribute('aria-hidden', 'true');
|
||||
if (value && value !== 'none') preview.append(iconSvg(value)); else preview.classList.add('is-empty');
|
||||
const name = element('span', 'icon-field-name', value && value !== 'none' ? ICON_LABELS[value] ?? value : none ?? 'Kein Symbol');
|
||||
const open = element('button', 'secondary icon-field-change', 'Ändern …');
|
||||
open.type = 'button';
|
||||
open.setAttribute('aria-haspopup', 'dialog');
|
||||
open.setAttribute('aria-label', `${label} ändern, aktuell: ${name.textContent}`);
|
||||
open.addEventListener('click', () => openIconDialog({ value, none, onChoose, returnFocus: open }));
|
||||
row.append(preview, name, open);
|
||||
wrap.append(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Das Auswahlfenster: Kategorien als Reiter links, die Symbole einer Kategorie als Kacheln mit
|
||||
* Namen. Ein Klick wählt und schließt, Escape schließt ohne Änderung. Geöffnet wird bei der
|
||||
* Kategorie des gewählten Symbols, der Fokus steht auf ihm.
|
||||
*/
|
||||
export function openIconDialog({ value, none, onChoose, returnFocus }) {
|
||||
const dialog = element('dialog', 'icon-dialog');
|
||||
dialog.setAttribute('aria-labelledby', 'icon-dialog-title');
|
||||
const head = element('div', 'icon-dialog-head');
|
||||
head.append(element('h2', null, 'Symbol wählen'));
|
||||
head.firstChild.id = 'icon-dialog-title';
|
||||
const close = element('button', 'icon-dialog-close');
|
||||
close.type = 'button'; close.setAttribute('aria-label', 'Schließen'); close.append(iconSvg('close'));
|
||||
close.addEventListener('click', () => dialog.close());
|
||||
head.append(close);
|
||||
const body = element('div', 'icon-dialog-body');
|
||||
const tablist = element('div', 'icon-dialog-tabs');
|
||||
tablist.setAttribute('role', 'tablist'); tablist.setAttribute('aria-orientation', 'vertical'); tablist.setAttribute('aria-label', 'Kategorien');
|
||||
const panel = element('div', 'icon-dialog-panel');
|
||||
panel.setAttribute('role', 'tabpanel'); panel.id = 'icon-dialog-panel';
|
||||
const tabs = [];
|
||||
let tiles = [];
|
||||
const choose = name => { dialog.close(); onChoose(name); };
|
||||
const show = (index, focusTile = false) => {
|
||||
tabs.forEach((tab, i) => { tab.setAttribute('aria-selected', String(i === index)); tab.tabIndex = i === index ? 0 : -1; });
|
||||
panel.setAttribute('aria-labelledby', tabs[index].id);
|
||||
panel.replaceChildren();
|
||||
const grid = element('div', 'icon-dialog-grid');
|
||||
tiles = ICON_GROUPS[index][1].map(name => {
|
||||
const tile = element('button', 'icon-choice');
|
||||
tile.type = 'button'; tile.dataset.icon = name;
|
||||
tile.setAttribute('aria-pressed', String(name === value));
|
||||
tile.tabIndex = -1;
|
||||
tile.append(iconSvg(name), element('span', 'icon-choice-name', ICON_LABELS[name]));
|
||||
tile.addEventListener('click', () => choose(name));
|
||||
grid.append(tile);
|
||||
return tile;
|
||||
});
|
||||
(tiles.find(tile => tile.dataset.icon === value) ?? tiles[0]).tabIndex = 0;
|
||||
panel.append(grid);
|
||||
if (focusTile) (tiles.find(tile => tile.tabIndex === 0)).focus();
|
||||
};
|
||||
ICON_GROUPS.forEach(([title], index) => {
|
||||
const tab = element('button', 'icon-dialog-tab', title);
|
||||
tab.type = 'button'; tab.id = `icon-tab-${index}`; tab.setAttribute('role', 'tab'); tab.setAttribute('aria-controls', panel.id);
|
||||
tab.addEventListener('click', () => show(index));
|
||||
tabs.push(tab); tablist.append(tab);
|
||||
});
|
||||
tablist.addEventListener('keydown', event => {
|
||||
const index = tabs.indexOf(document.activeElement);
|
||||
const step = { ArrowDown: 1, ArrowRight: 1, ArrowUp: -1, ArrowLeft: -1 }[event.key];
|
||||
if (index < 0 || !step) return;
|
||||
event.preventDefault();
|
||||
const next = (index + step + tabs.length) % tabs.length;
|
||||
show(next); tabs[next].focus();
|
||||
});
|
||||
panel.addEventListener('keydown', event => {
|
||||
const index = tiles.indexOf(document.activeElement);
|
||||
if (index < 0) return;
|
||||
const columns = Math.max(1, Math.round(panel.querySelector('.icon-dialog-grid').clientWidth / tiles[0].offsetWidth));
|
||||
const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: columns, ArrowUp: -columns }[event.key];
|
||||
const target = event.key === 'Home' ? 0 : event.key === 'End' ? tiles.length - 1 : step ? Math.min(tiles.length - 1, Math.max(0, index + step)) : null;
|
||||
if (target === null) return;
|
||||
event.preventDefault();
|
||||
tiles[index].tabIndex = -1; tiles[target].tabIndex = 0; tiles[target].focus();
|
||||
});
|
||||
body.append(tablist, panel);
|
||||
const foot = element('div', 'icon-dialog-foot');
|
||||
if (none) {
|
||||
const without = element('button', 'secondary', none);
|
||||
without.type = 'button'; without.dataset.icon = 'none';
|
||||
without.setAttribute('aria-pressed', String(value === 'none'));
|
||||
without.addEventListener('click', () => choose('none'));
|
||||
foot.append(without);
|
||||
}
|
||||
const cancel = element('button', 'text-button', 'Abbrechen');
|
||||
cancel.type = 'button'; cancel.addEventListener('click', () => dialog.close());
|
||||
foot.append(cancel);
|
||||
dialog.append(head, body, foot);
|
||||
dialog.addEventListener('close', () => { dialog.remove(); if (returnFocus?.isConnected) returnFocus.focus(); });
|
||||
// Ein Klick auf den abgedunkelten Rand schließt, wie bei den anderen Fenstern des Editors.
|
||||
dialog.addEventListener('click', event => { if (event.target === dialog) dialog.close(); });
|
||||
document.body.append(dialog);
|
||||
const start = Math.max(0, ICON_GROUPS.findIndex(([, names]) => names.includes(value)));
|
||||
dialog.showModal();
|
||||
show(start, true);
|
||||
return dialog;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128"><rect width="128" height="128" rx="32" fill="#182d2b"/><path d="M36 28h16v42l26-28h22L68 76l34 28H78L52 82v22H36z" fill="#e5f0b6"/></svg>
|
||||
|
After Width: | Height: | Size: 225 B |
@@ -38,6 +38,7 @@ export const PREVIEWS = {
|
||||
contact: '<rect x="2.5" y="3.5" width="19" height="9" rx="1.5"/><path d="M2.9 4.4l9.1 5 9.1-5"/>',
|
||||
logos: [2.5, 8, 13.5, 19].map(x => `<rect x="${x}" y="5.5" width="4.5" height="5" rx="1.2"/>`).join(''),
|
||||
progress: '<rect x="2" y="6" width="20" height="4" rx="2"/><rect x="2" y="6" width="12" height="4" rx="2" fill="currentColor" stroke="none"/>',
|
||||
social: [4.5, 12, 19.5].map(x => `<circle cx="${x}" cy="8" r="3"/>`).join('') + '<path d="M3.6 8h1.8M11.1 8h1.8M18.6 8h1.8"/>',
|
||||
|
||||
// Gruppen
|
||||
gallery: [2.5, 12.5].flatMap(x => [2.5, 8.5].map(y => `<rect x="${x}" y="${y}" width="9" height="5" rx="1.2"/>`)).join(''),
|
||||
@@ -53,6 +54,7 @@ export const PREVIEWS = {
|
||||
postnav: '<path d="M9.4 4.6L6 8l3.4 3.4M14.6 4.6L18 8l-3.4 3.4"/><path d="M6.4 8h3.6M14 8h3.6"/>',
|
||||
breadcrumb: `${bar(2, 7.2, 5, 1.7)}<path d="M8.6 6.3l1.6 1.7-1.6 1.7"/>${bar(11.6, 7.2, 4, 1.7)}<path d="M17.2 6.3l1.6 1.7-1.6 1.7"/>${bar(20.2, 7.2, 2, 1.7)}`,
|
||||
menu: `${bar(2, 7.2, 5, 1.8)}${bar(9, 7.2, 6, 1.8)}${bar(17, 7.2, 5, 1.8)}`,
|
||||
language: '<circle cx="12" cy="8" r="5.6"/><path d="M6.4 8h11.2M12 2.4c-2.4 2.8-2.4 8.4 0 11.2M12 2.4c2.4 2.8 2.4 8.4 0 11.2"/>',
|
||||
form: '<rect x="2.5" y="2" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="6.8" width="19" height="3.6" rx="1.2"/><rect x="2.5" y="11.6" width="8" height="3.4" rx="1.2" fill="currentColor" stroke="none"/>',
|
||||
legal: `${bar(2, 4, 20, 1.6)}${bar(2, 7.4, 16, 1.6)}${bar(2, 10.8, 11, 1.6)}`,
|
||||
partialref: '<rect x="2.5" y="4.5" width="13" height="9" rx="1.5"/><path d="M6.5 4.5V2h13v9h-2.5"/>',
|
||||
|
||||
@@ -99,3 +99,86 @@
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
/*
|
||||
* Der PDF-Betrachter - in derselben Datei wie der Bildbetrachter, damit eine Seite mit beidem
|
||||
* nicht zwei Skripte laedt.
|
||||
*
|
||||
* Ohne dieses Skript ist ein Link auf ein PDF ein gewöhnlicher Link: der Browser öffnet die
|
||||
* Datei. Das Skript zeigt sie stattdessen in einem Rahmen über der Seite, mit dem Betrachter,
|
||||
* den der Browser ohnehin mitbringt - kein eigener Nachbau, der ein Megabyte wiegt.
|
||||
*
|
||||
* Wo der Browser keinen solchen Betrachter hat oder ihn im Rahmen nur halb zeigt, bleibt es beim
|
||||
* neuen Tab: Chrome auf Android hat keinen, und Safari auf dem iPhone zeigt im Rahmen nur die
|
||||
* erste Seite. Ein Link mit „In neuem Tab öffnen“ bleibt ohnehin, was er ist.
|
||||
*/
|
||||
(() => {
|
||||
const PDF = /^\/media\/[a-f0-9]{32}\.pdf$/;
|
||||
const links = [...document.querySelectorAll('a[href]')].filter(link => {
|
||||
const url = new URL(link.href, location.href);
|
||||
|
||||
return url.origin === location.origin && PDF.test(url.pathname) && '_blank' !== link.target;
|
||||
});
|
||||
if (0 === links.length) {
|
||||
return;
|
||||
}
|
||||
const eingebettet = false !== navigator.pdfViewerEnabled && !matchMedia('(pointer: coarse)').matches;
|
||||
|
||||
let dialog = null;
|
||||
let rahmen = null;
|
||||
let titel = null;
|
||||
let laden = null;
|
||||
|
||||
const baue = () => {
|
||||
dialog = document.createElement('dialog');
|
||||
dialog.className = 'pdf-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'pdf-dialog-title');
|
||||
dialog.innerHTML = `
|
||||
<div class="pdf-bar">
|
||||
<p class="pdf-title" id="pdf-dialog-title"></p>
|
||||
<a class="pdf-action pdf-download" download>Herunterladen</a>
|
||||
<a class="pdf-action pdf-open" target="_blank" rel="noopener">Im neuen Tab</a>
|
||||
<button class="pdf-close" type="button" aria-label="Schließen">×</button>
|
||||
</div>
|
||||
<iframe class="pdf-frame" title="PDF-Dokument"></iframe>
|
||||
`;
|
||||
document.body.append(dialog);
|
||||
rahmen = dialog.querySelector('.pdf-frame');
|
||||
titel = dialog.querySelector('.pdf-title');
|
||||
laden = [dialog.querySelector('.pdf-download'), dialog.querySelector('.pdf-open')];
|
||||
dialog.querySelector('.pdf-close').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('click', event => {
|
||||
if (event.target === dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
// Der Rahmen wird geleert, sonst läuft ein eingebettetes Video oder Audio im PDF weiter
|
||||
// und das nächste Öffnen zeigt kurz das vorige Dokument.
|
||||
dialog.addEventListener('close', () => rahmen.removeAttribute('src'));
|
||||
};
|
||||
|
||||
for (const link of links) {
|
||||
link.addEventListener('click', event => {
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || 1 === event.button) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!eingebettet) {
|
||||
window.open(link.href, '_blank', 'noopener');
|
||||
|
||||
return;
|
||||
}
|
||||
if (null === dialog) {
|
||||
baue();
|
||||
}
|
||||
const name = (link.getAttribute('aria-label') || link.textContent || '').trim();
|
||||
titel.textContent = name || 'Dokument';
|
||||
rahmen.title = name ? `PDF: ${name}` : 'PDF-Dokument';
|
||||
for (const aktion of laden) {
|
||||
aktion.href = link.href;
|
||||
}
|
||||
rahmen.src = link.href;
|
||||
dialog.showModal();
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,26 @@
|
||||
import { openPageDialog } from './page-picker.js';
|
||||
import { openDocuments } from './media.js';
|
||||
|
||||
/*
|
||||
* Ein Linkfeld in der Verwaltung: die Adresse bleibt ein Textfeld - eine fremde Seite tippt man
|
||||
* ein -, daneben „Seite wählen“ und „PDF wählen“. Ohne Skript bleibt das Textfeld allein.
|
||||
*
|
||||
* Markup: <div data-link-field> mit einem <input data-link-input> darin. Felder, die später
|
||||
* erscheinen (neue Menüeinträge), bekommen ihre Knöpfe beim ersten Fokus.
|
||||
*/
|
||||
const enhance = field => {
|
||||
if (field.dataset.linkReady) return;
|
||||
const input = field.querySelector('[data-link-input]');
|
||||
if (!input) return;
|
||||
field.dataset.linkReady = '1';
|
||||
const row = document.createElement('div'); row.className = 'link-field-actions';
|
||||
const choose = value => { input.value = value; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); input.focus(); };
|
||||
const page = document.createElement('button'); page.type = 'button'; page.className = 'text-button'; page.textContent = 'Seite wählen';
|
||||
page.addEventListener('click', () => openPageDialog({ onChoose: item => choose(item.path === 'home' ? '/' : `/${item.path}`) }));
|
||||
const pdf = document.createElement('button'); pdf.type = 'button'; pdf.className = 'text-button'; pdf.textContent = 'PDF wählen';
|
||||
pdf.addEventListener('click', () => openDocuments({ onChoose: item => choose(item.src) }));
|
||||
row.append(page, pdf); input.after(row);
|
||||
};
|
||||
const all = () => document.querySelectorAll('[data-link-field]').forEach(enhance);
|
||||
all();
|
||||
new MutationObserver(all).observe(document.body, { childList: true, subtree: true });
|
||||
@@ -0,0 +1,44 @@
|
||||
/* The media library dialog. Loaded by the editor and by every screen that picks an image from
|
||||
the library (the site icon in the settings), so it does not rely on editor.css. */
|
||||
.media-dialog{padding:28px;background:var(--paper);border:1px solid var(--line);border-radius:12px;color:var(--ink);box-shadow:0 20px 80px #1b2a2433}
|
||||
.media-dialog::backdrop{background:#172e2966}
|
||||
.media-dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.media-dialog .panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin:0 0 12px}
|
||||
.media-launch{margin-top:14px;white-space:normal}
|
||||
.media-dialog{width:calc(100% - 32px);max-width:1100px;max-height:calc(100dvh - 32px);overflow:auto}
|
||||
.media-heading{display:flex;justify-content:space-between;gap:20px;align-items:center}
|
||||
.media-heading h2{margin:0}
|
||||
.media-heading .eyebrow{margin-bottom:8px}
|
||||
.media-tools{display:grid;grid-template-columns:1fr 1fr;gap:24px}
|
||||
.media-tools label{margin-top:10px}
|
||||
.media-layout{display:grid;grid-template-columns:minmax(0,1fr) 270px;gap:24px}
|
||||
.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:12px;align-content:start;max-height:430px;overflow:auto;padding:4px}
|
||||
.media-card{border:2px solid var(--line);border-radius:9px;padding:7px;background:white;color:var(--ink);display:flex;flex-direction:column;gap:7px;text-align:left;min-width:0}
|
||||
.media-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
|
||||
.media-card img{width:100%;height:115px;object-fit:cover;border-radius:5px}
|
||||
.media-card strong{font-size:12px;overflow-wrap:anywhere}
|
||||
.media-card span{font-size:11px;color:var(--muted)}
|
||||
.media-details{padding-left:22px;border-left:1px solid var(--line)}
|
||||
.media-details button,.media-details a{margin-top:12px;display:block}
|
||||
.media-details input{font-size:13px}
|
||||
.media-footer{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:15px}
|
||||
.media-footer label{margin:0;font-size:12px}
|
||||
#media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
|
||||
#media-status{font-size:12px;margin-block:15px}
|
||||
.media-dialog> .panel-hint{margin-top:20px}
|
||||
.media-details>p{font-size:12px}
|
||||
@media(max-width:767px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
|
||||
/* PDFs: eine Liste statt eines Rasters - es gibt nichts zu sehen außer dem Titel. */
|
||||
.document-dialog{max-width:720px}
|
||||
.document-list{display:grid;gap:8px;max-height:360px;overflow:auto;padding:4px}
|
||||
.document-card{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:12px;border:2px solid var(--line);border-radius:9px;padding:10px 12px;background:white;color:var(--ink);text-align:left}
|
||||
.document-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
|
||||
.document-card strong{font-size:13px;overflow-wrap:anywhere}
|
||||
.document-card span{font-size:11px;color:var(--muted)}
|
||||
.document-card .document-badge{font-weight:700;color:#8b2f22;background:#fff0eb;border-radius:5px;padding:4px 6px;letter-spacing:.04em}
|
||||
.media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
|
||||
.href-pickers{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 4px}
|
||||
.href-document{margin:0}
|
||||
/* Seite als Linkziel wählen: schmal, die Trefferliste darf überstehen. */
|
||||
.page-dialog{max-width:560px;min-height:420px;overflow:visible}
|
||||
.page-dialog .content-picker{margin-top:16px}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Dragging images onto the upload field, and saying which files are about to go up.
|
||||
* Dragging images and PDFs onto the upload field, and saying which files are about to go up.
|
||||
*
|
||||
* Everything here is an addition. Without this script the field is still a file input with a
|
||||
* label: clicking it opens the file picker and the form submits the same way. The script never
|
||||
@@ -9,19 +9,19 @@ const zone = document.querySelector('[data-dropzone]');
|
||||
const input = zone?.querySelector('input[type="file"]');
|
||||
const chosen = document.querySelector('[data-chosen]');
|
||||
if (zone && input && chosen) {
|
||||
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
|
||||
|
||||
const describe = (files, ignored = 0) => {
|
||||
if (!files.length) {
|
||||
chosen.textContent = ignored
|
||||
? `Keine Bilddatei dabei. ${ignored} Datei(en) übersprungen – erlaubt sind JPEG, PNG und WebP.`
|
||||
? `Keine passende Datei dabei. ${ignored} Datei(en) übersprungen – erlaubt sind JPEG, PNG, WebP und PDF.`
|
||||
: 'Noch keine Datei gewählt.';
|
||||
return;
|
||||
}
|
||||
const names = [...files].map(file => file.name).join(', ');
|
||||
const size = [...files].reduce((total, file) => total + file.size, 0);
|
||||
chosen.textContent = `${files.length} Datei(en) gewählt (${(size / 1048576).toFixed(1)} MB): ${names}`
|
||||
+ (ignored ? ` · ${ignored} Datei(en) übersprungen, erlaubt sind JPEG, PNG und WebP.` : '');
|
||||
+ (ignored ? ` · ${ignored} Datei(en) übersprungen, erlaubt sind JPEG, PNG, WebP und PDF.` : '');
|
||||
};
|
||||
|
||||
input.addEventListener('change', () => describe(input.files));
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
/*
|
||||
* Bildfelder in Formularen der Verwaltung: Vorschaubild beim Teilen, Beitragsbild.
|
||||
*
|
||||
* Über den Dokumentknoten statt je Feld, weil die Suche in der Inhaltsliste die Ergebnisse
|
||||
* austauscht - ein Feld, das danach erscheint, funktioniert trotzdem.
|
||||
*/
|
||||
document.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-media-choose], [data-media-remove]');
|
||||
const field = button?.closest('[data-media-field]');
|
||||
if (!field) return;
|
||||
const input = field.querySelector('input[type="hidden"]');
|
||||
const thumb = field.querySelector('.media-field-thumb');
|
||||
const name = field.querySelector('.media-field-name');
|
||||
const choose = field.querySelector('[data-media-choose]');
|
||||
const remove = field.querySelector('[data-media-remove]');
|
||||
const show = item => {
|
||||
input.value = item?.id ?? '';
|
||||
thumb.hidden = !item;
|
||||
if (item) thumb.src = item.variants[0].src; else thumb.removeAttribute('src');
|
||||
name.textContent = item ? item.title : 'Kein Bild gewählt';
|
||||
choose.textContent = item ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen';
|
||||
remove.hidden = !item;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
};
|
||||
if (button.matches('[data-media-remove]')) { show(null); choose.focus(); return; }
|
||||
// Kein Alternativtext nötig: das Bild erscheint als Vorschau beim Teilen oder in einer Karte,
|
||||
// deren Titel schon sagt, worum es geht.
|
||||
openMedia({ needsAlt: false, onChoose: ([item]) => { if (item) show(item); } });
|
||||
});
|
||||
+70
-3
@@ -40,7 +40,8 @@ export function createMediaPreview(initial = {}) {
|
||||
return { element: wrap, update };
|
||||
}
|
||||
|
||||
export function openMedia({ multiple = false, onChoose } = {}) {
|
||||
/** needsAlt: false for images that never appear as content, such as the site icon. */
|
||||
export function openMedia({ multiple = false, onChoose, needsAlt = true } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = element('dialog', '', 'media-dialog'); dialog.id = 'media-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'media-title');
|
||||
@@ -128,15 +129,81 @@ export function openMedia({ multiple = false, onChoose } = {}) {
|
||||
}
|
||||
$('#media-upload').value = ''; lock(false); render(); details(); error(failures.join(' · '));
|
||||
});
|
||||
$('#media-insert').hidden = !onChoose; $('#decorative-choice').hidden = !onChoose;
|
||||
$('#media-insert').hidden = !onChoose; $('#decorative-choice').hidden = !onChoose || !needsAlt;
|
||||
$('#media-insert').addEventListener('click', () => {
|
||||
const chosen = [...selected].map(id => items.find(item => item.id === id)).filter(item => item && !item.archived);
|
||||
const decorative = $('#media-decorative').checked;
|
||||
if (!decorative && chosen.some(item => !item.alt.trim())) { error('Bitte für die gewählten Bilder einen Alternativtext speichern oder die dekorative Verwendung wählen.'); return; }
|
||||
if (needsAlt && !decorative && chosen.some(item => !item.alt.trim())) { error('Bitte für die gewählten Bilder einen Alternativtext speichern oder die dekorative Verwendung wählen.'); return; }
|
||||
onChoose(chosen.map(item => ({ ...item, decorative }))); dialog.close();
|
||||
});
|
||||
dialog.showModal(); lock(true);
|
||||
api().then(data => { items = data.items; token = data.csrfToken; lock(false); render(); }).catch(err => { lock(false); error(err.message || 'Medien konnten nicht geladen werden. Bitte erneut öffnen.'); });
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein PDF aus der Mediathek wählen - oder eines hochladen und gleich wählen.
|
||||
*
|
||||
* Ein eigener, schmaler Dialog statt einer Betriebsart des Bilderdialogs: ein PDF hat keine
|
||||
* Vorschau, keinen Alternativtext und keine Mehrfachauswahl. Gewählt wird die Adresse, die dann
|
||||
* im Linkziel steht.
|
||||
*/
|
||||
export function openDocuments({ onChoose } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = element('dialog', '', 'media-dialog document-dialog');
|
||||
dialog.setAttribute('aria-labelledby', 'document-title');
|
||||
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">DOKUMENTE</p><h2 id="document-title">PDF wählen</h2></div><button class="secondary" id="document-close">Schließen</button></div>
|
||||
<p class="panel-hint">PDF bis 20 MB. Es wird unverändert abgelegt und ist über seine Adresse öffentlich erreichbar – nichts Vertrauliches hochladen. Ohne „In neuem Tab öffnen“ zeigt die Website es in einem Betrachter über der Seite.</p>
|
||||
<div class="media-tools"><div><label for="document-search">PDFs suchen</label><input id="document-search" type="search" placeholder="Titel"></div><div><label for="document-upload">PDF hochladen</label><input id="document-upload" type="file" accept="application/pdf,.pdf" disabled></div></div>
|
||||
<p id="document-status" role="status" aria-live="polite">Wird geladen …</p><p id="document-error" class="media-error" role="alert" hidden></p>
|
||||
<div id="document-list" class="document-list" role="group" aria-label="PDF auswählen"></div>
|
||||
<div class="media-footer"><span></span><button id="document-insert" class="primary" disabled>PDF übernehmen</button></div>`;
|
||||
document.body.append(dialog);
|
||||
const $ = selector => dialog.querySelector(selector);
|
||||
let items = [], chosen = null, token;
|
||||
const error = message => { $('#document-error').textContent = message; $('#document-error').hidden = !message; };
|
||||
const size = bytes => `${(bytes / 1048576).toLocaleString('de', { maximumFractionDigits: 1, minimumFractionDigits: 1 })} MB`;
|
||||
const base = document.querySelector('meta[name="admin-base"]').content + '/api/dokumente';
|
||||
function render() {
|
||||
const query = $('#document-search').value.toLocaleLowerCase('de');
|
||||
const visible = items.filter(item => !item.archived && item.title.toLocaleLowerCase('de').includes(query));
|
||||
$('#document-list').replaceChildren();
|
||||
for (const item of visible) {
|
||||
const card = element('button', '', 'document-card'); card.type = 'button';
|
||||
card.setAttribute('aria-pressed', String(chosen?.id === item.id));
|
||||
card.append(element('span', 'PDF', 'document-badge'), element('strong', item.title), element('span', size(item.bytes)));
|
||||
card.addEventListener('click', () => { chosen = item; render(); card.focus(); });
|
||||
card.addEventListener('dblclick', () => { chosen = item; $('#document-insert').click(); });
|
||||
$('#document-list').append(card);
|
||||
}
|
||||
if (!visible.length) $('#document-list').append(element('p', items.length ? 'Kein PDF mit diesem Titel.' : 'Noch keine PDFs. Lade oben eines hoch.', 'muted'));
|
||||
$('#document-status').textContent = `${items.filter(item => !item.archived).length} PDFs${chosen ? ` · gewählt: ${chosen.title}` : ''}`;
|
||||
$('#document-insert').disabled = !chosen;
|
||||
}
|
||||
$('#document-search').addEventListener('input', render);
|
||||
$('#document-close').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); });
|
||||
$('#document-upload').addEventListener('change', async () => {
|
||||
const file = $('#document-upload').files[0]; if (!file) return;
|
||||
error('');
|
||||
if (file.size > 20 * 1024 * 1024) { error(`${file.name}: größer als 20 MB`); $('#document-upload').value = ''; return; }
|
||||
$('#document-status').textContent = `Wird hochgeladen: ${file.name}`;
|
||||
try {
|
||||
const form = new FormData(); form.append('file', file);
|
||||
const response = await fetch(base + '/upload', { method: 'POST', headers: { 'X-CSRF-Token': token }, body: form });
|
||||
if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.');
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || `Upload fehlgeschlagen (${response.status}).`);
|
||||
items.unshift(data.item); chosen = data.item;
|
||||
} catch (err) { error(`${file.name}: ${err.message || 'Verbindung unterbrochen'}`); }
|
||||
$('#document-upload').value = ''; render();
|
||||
});
|
||||
$('#document-insert').addEventListener('click', () => { if (chosen) { onChoose?.(chosen); dialog.close(); } });
|
||||
dialog.showModal();
|
||||
fetch(base, { credentials: 'same-origin' }).then(async response => {
|
||||
if (!response.ok || response.redirected) throw new Error('PDFs konnten nicht geladen werden. Bitte neu anmelden.');
|
||||
// Hochladen erst mit Token - sonst weist der Server eine zu früh gewählte Datei ab.
|
||||
const data = await response.json(); items = data.items; token = data.csrfToken; $('#document-upload').disabled = false; render();
|
||||
}).catch(err => { $('#document-status').textContent = ''; error(err.message); });
|
||||
}
|
||||
|
||||
document.querySelector('#open-media-library')?.addEventListener('click', () => openMedia());
|
||||
@@ -1,4 +1,8 @@
|
||||
import { setupPicker } from './page-picker.js';
|
||||
import './link-field.js';
|
||||
import { renderIconField, iconSvg, ICON_LABELS } from './icons.js';
|
||||
import { createColorControl } from './color-control.js';
|
||||
import { buttonSample } from './button-design.js';
|
||||
|
||||
const form = document.querySelector('#menu-editor');
|
||||
const list = form.querySelector('#menu-items');
|
||||
@@ -7,7 +11,8 @@ const error = form.querySelector('#menu-error');
|
||||
const history = [];
|
||||
let serial = list.children.length, pendingPage = null, busy = false;
|
||||
const el = (tag, className = '', text = '') => { const node = document.createElement(tag); node.className = className; node.textContent = text; return node; };
|
||||
const iconNames = { app: 'App', web: 'Web', design: 'Gestaltung', network: 'Netzwerk', hardware: 'Computer', shop: 'Shop', support: 'Support', shield: 'Sicherheit', clock: 'Uhr', star: 'Stern', phone: 'Telefon', mail: 'E-Mail', pin: 'Standort', check: 'Haken' };
|
||||
const iconNames = ICON_LABELS;
|
||||
const buttonSchema = JSON.parse(form.dataset.buttonSchema || '{}');
|
||||
const menuIcons = JSON.parse(form.dataset.icons || '[]');
|
||||
const chrome = () => {
|
||||
const handle = el('button', 'menu-drag-handle'); handle.type = 'button'; handle.dataset.menuDrag = '';
|
||||
@@ -39,7 +44,9 @@ function refresh() {
|
||||
const name = row.querySelector('[data-key="label"]').value || 'Neuer Eintrag';
|
||||
row.querySelector('.menu-entry-title').textContent = name;
|
||||
const icon = row.querySelector('[data-key="icon"]')?.value, position = row.querySelector('[data-key="iconPosition"]')?.value;
|
||||
const graphic = icon && document.getElementById(`menu-icon-${icon}`)?.content.cloneNode(true);
|
||||
row.querySelector('.menu-entry-title svg')?.remove();
|
||||
const graphic = icon && iconSvg(icon);
|
||||
if (graphic) { graphic.setAttribute('width', '18'); graphic.setAttribute('height', '18'); }
|
||||
if (graphic) row.querySelector('.menu-entry-title')[position === 'after' ? 'append' : 'prepend'](graphic);
|
||||
row.querySelector('[data-menu-drag]')?.setAttribute('aria-label', `${name} verschieben`);
|
||||
row.querySelectorAll('[data-key="icon"] option').forEach(option => { if (iconNames[option.value]) option.textContent = iconNames[option.value]; });
|
||||
@@ -74,9 +81,16 @@ function createRow(data) {
|
||||
for (const key of ['position', 'level', 'type']) field(key, data[key] ?? 0, '', 'hidden');
|
||||
const details = el('details'); const summary = el('summary');
|
||||
const { handle, chevron } = chrome();
|
||||
summary.append(handle, el('strong', 'menu-entry-title', data.label), el('span', 'menu-entry-kind', data.type === 'page' ? 'Seitenlink' : 'Eigener Link'), chevron);
|
||||
const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(details);
|
||||
// Der Ziehknopf steht neben der Zusammenfassung, nicht in ihr - kein Knopf in einem Knopf.
|
||||
summary.append(el('strong', 'menu-entry-title', data.label), el('span', 'menu-entry-kind', data.type === 'page' ? 'Seitenlink' : 'Eigener Link'), chevron);
|
||||
const body = el('div', 'menu-entry-fields'); details.append(summary, body); row.append(handle, details);
|
||||
const label = field('label', data.label, 'Beschriftung'); label.required = true; label.maxLength = 80;
|
||||
// Je weitere Sprache eine eigene Beschriftung (ADR 111); leer zeigt die der Hauptsprache.
|
||||
for (const language of JSON.parse(form.dataset.languages || '[]')) {
|
||||
const translated = field(`label_${language.code}`, data[`label_${language.code}`] ?? data.labels?.[language.code] ?? '', `Beschriftung ${language.name}`);
|
||||
translated.maxLength = 80; translated.lang = language.code; translated.placeholder = `Leer: wie „${data.label}“`;
|
||||
if (language.rtl) translated.dir = 'rtl';
|
||||
}
|
||||
if (data.type === 'page') {
|
||||
const picker = form.querySelector('.menu-add .content-picker').cloneNode(true); delete picker.dataset.ready;
|
||||
const input = picker.querySelector('[role="combobox"]'); input.id = `menu-target-${id}`; input.value = data.pageDisplay || '';
|
||||
@@ -86,14 +100,18 @@ function createRow(data) {
|
||||
picker.querySelector('.picker-value').value = data.pageId || ''; picker.querySelector('.picker-status').textContent = '';
|
||||
body.append(picker); setupPicker(picker);
|
||||
field('anchor', data.anchor, 'Sprungziel (optional)').maxLength = 65;
|
||||
} else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; }
|
||||
anchorChoice(row);
|
||||
} else { const url = field('url', data.url, 'Adresse'); url.required = true; url.maxLength = 2048; linkButtons(url); }
|
||||
const newTab = field('newTab', '1', 'In neuem Tab öffnen', 'checkbox'); newTab.checked = data.newTab === true || data.newTab === '1';
|
||||
for (const [key, label, choices] of [['icon', 'Icon', { '': 'Kein Icon', ...Object.fromEntries(menuIcons.map(icon => [icon, iconNames[icon] || icon])) }], ['iconPosition', 'Icon-Position', { before: 'Vor dem Text', after: 'Nach dem Text' }]]) {
|
||||
const icons = el('div', 'menu-icon-fields'); body.append(icons);
|
||||
for (const [key, label, choices] of [['icon', 'Symbol', { '': 'Kein Symbol', ...Object.fromEntries(menuIcons.map(icon => [icon, iconNames[icon] || icon])) }], ['iconPosition', 'Symbol steht', { before: 'Vor dem Text', after: 'Nach dem Text' }]]) {
|
||||
const select = el('select'); select.dataset.key = key; select.id = `menu-${key}-${id}`;
|
||||
for (const [value, caption] of Object.entries(choices)) select.append(new Option(caption, value));
|
||||
select.value = data[key] || (key === 'iconPosition' ? 'before' : '');
|
||||
const caption = el('label', '', label); caption.htmlFor = select.id; body.append(caption, select);
|
||||
const wrap = el('div'); const caption = el('label', '', label); caption.htmlFor = select.id; wrap.append(caption, select); icons.append(wrap);
|
||||
}
|
||||
body.append(buttonFieldset(data, id));
|
||||
enhance(row);
|
||||
const actions = el('div', 'menu-entry-actions'); actions.setAttribute('role', 'group');
|
||||
for (const [action, caption] of Object.entries({ up: '↑', down: '↓', indent: 'Als Unterpunkt', outdent: 'Hauptebene', remove: 'Entfernen' })) {
|
||||
const button = el('button', `text-button${action === 'remove' ? ' danger-text' : ''}`, caption); button.type = 'button'; button.dataset.menuAction = action; actions.append(button);
|
||||
@@ -101,6 +119,157 @@ function createRow(data) {
|
||||
row.append(actions); return row;
|
||||
}
|
||||
|
||||
/*
|
||||
* Das Sprungziel wird aus den Ankern der verknüpften Seite gewählt. Das Textfeld bleibt, was
|
||||
* gespeichert wird - versteckt, und ohne Skript sichtbar zum Eintippen.
|
||||
*/
|
||||
const adminBase = document.querySelector('meta[name="admin-base"]').content;
|
||||
function anchorChoice(row) {
|
||||
const input = row.querySelector('input[data-key="anchor"]');
|
||||
const pageValue = row.querySelector('.picker-value');
|
||||
if (!input || !pageValue || input.dataset.enhanced) return;
|
||||
input.dataset.enhanced = '1';
|
||||
const label = row.querySelector(`label[for="${input.id}"]`);
|
||||
const select = el('select'); select.id = input.id; input.id += '-value'; input.type = 'hidden';
|
||||
if (label) { label.htmlFor = select.id; label.textContent = 'Sprungziel'; }
|
||||
input.after(select);
|
||||
const help = el('p', 'field-help', ''); select.after(help);
|
||||
let request = 0;
|
||||
const load = async () => {
|
||||
const mine = ++request, current = input.value;
|
||||
select.replaceChildren(new Option('Seitenanfang', ''));
|
||||
if (current) select.append(new Option(`#${current}`, current));
|
||||
select.value = current;
|
||||
help.textContent = '';
|
||||
if (!pageValue.value) { select.disabled = true; return; }
|
||||
select.disabled = false;
|
||||
try {
|
||||
const response = await fetch(`${adminBase}/api/sprungziele/${encodeURIComponent(pageValue.value)}`, { credentials: 'same-origin' });
|
||||
if (!response.ok || response.redirected) throw new Error();
|
||||
const { items } = await response.json();
|
||||
if (mine !== request) return;
|
||||
select.replaceChildren(new Option('Seitenanfang', ''));
|
||||
for (const item of items) select.append(new Option(item.label, item.anchor));
|
||||
if (current && !items.some(item => item.anchor === current)) select.append(new Option(`#${current} (auf der Seite nicht gefunden)`, current));
|
||||
select.value = current;
|
||||
help.textContent = items.length ? '' : 'Diese Seite hat keine Sprungziele. Im Editor lässt sich einer Sektion, Zeile oder Spalte ein Sprungziel geben.';
|
||||
} catch {
|
||||
if (mine === request) help.textContent = 'Sprungziele konnten nicht geladen werden.';
|
||||
}
|
||||
};
|
||||
select.addEventListener('change', () => { input.value = select.value; input.dispatchEvent(new Event('change', { bubbles: true })); });
|
||||
pageValue.addEventListener('change', () => { input.value = ''; load(); });
|
||||
load();
|
||||
}
|
||||
list.querySelectorAll('.menu-entry').forEach(anchorChoice);
|
||||
function linkButtons(input) {
|
||||
// Die Adresse eines eigenen Links: Seite oder PDF wählen, eine fremde Adresse tippen.
|
||||
if (input.closest('[data-link-field]')) return;
|
||||
input.dataset.linkInput = '';
|
||||
const box = el('div', 'link-field'); box.dataset.linkField = '';
|
||||
input.before(box); box.append(input);
|
||||
}
|
||||
list.querySelectorAll('input[data-key="url"]').forEach(linkButtons);
|
||||
|
||||
/*
|
||||
* Ein Eintrag als Button (ADR 110): dieselben Felder wie die Vorlage menu-entry.html.twig, damit
|
||||
* neue und geladene Zeilen gleich aussehen und dieselben Namen tragen. Leer heißt Website-Standard.
|
||||
*/
|
||||
const BUTTON_COLORS = { buttonFill: 'Fläche', buttonInk: 'Schrift', buttonHoverFill: 'Fläche beim Überfahren', buttonHoverInk: 'Schrift beim Überfahren', buttonIconColor: 'Symbolfarbe' };
|
||||
function buttonFieldset(data, id) {
|
||||
const set = el('fieldset', 'menu-button-fields'); set.dataset.buttonFields = '';
|
||||
set.append(el('legend', '', 'Button'));
|
||||
const toggle = el('input'); toggle.type = 'checkbox'; toggle.dataset.key = 'button'; toggle.value = '1'; toggle.id = `menu-button-${id}`;
|
||||
toggle.checked = data.button === '1' || data.button === true;
|
||||
const toggleLabel = el('label', 'check-label', ' Als Button darstellen'); toggleLabel.htmlFor = toggle.id; toggleLabel.prepend(toggle);
|
||||
const hidden = el('input'); hidden.type = 'hidden'; hidden.dataset.key = 'buttonId'; hidden.value = data.buttonId || '';
|
||||
const options = el('div', 'menu-button-options'); options.dataset.buttonOptions = ''; options.hidden = !toggle.checked;
|
||||
const select = (key, label, choices, value) => {
|
||||
const wrap = el('div'); const input = el('select'); input.dataset.key = key; input.id = `menu-${key}-${id}`;
|
||||
for (const [option, caption] of Object.entries(choices)) input.append(new Option(caption, option));
|
||||
input.value = value; const caption = el('label', '', label); caption.htmlFor = input.id; wrap.append(caption, input); options.append(wrap);
|
||||
};
|
||||
for (const [key, label] of [['shape', 'Form'], ['appearance', 'Darstellung'], ['size', 'Größe']]) {
|
||||
const field = `button${key[0].toUpperCase()}${key.slice(1)}`;
|
||||
select(field, label, { '': 'Website-Standard', ...(buttonSchema.buttonOptions?.[key] ?? {}) }, data[field] || '');
|
||||
}
|
||||
for (const [key, label] of Object.entries(BUTTON_COLORS)) {
|
||||
const wrap = el('div'); wrap.dataset.colorField = '';
|
||||
const input = el('input'); input.dataset.key = key; input.id = `menu-${key}-${id}`; input.value = data[key] || ''; input.placeholder = 'Website-Standard';
|
||||
const caption = el('label', '', label); caption.htmlFor = input.id; wrap.append(caption, input); options.append(wrap);
|
||||
}
|
||||
select('iconMotion', 'Symbol beim Überfahren', { none: 'Ruhig', slide: 'Rückt', reveal: 'Erscheint' }, data.iconMotion || 'none');
|
||||
const only = el('input'); only.type = 'checkbox'; only.dataset.key = 'iconOnly'; only.value = '1'; only.id = `menu-iconOnly-${id}`; only.checked = data.iconOnly === '1';
|
||||
const onlyLabel = el('label', 'check-label', ' Nur das Symbol zeigen'); onlyLabel.htmlFor = only.id; onlyLabel.prepend(only); options.append(onlyLabel);
|
||||
select('buttonMobile', 'Auf dem Telefon', buttonSchema.mobile ?? { menu: 'Im aufgeklappten Menü' }, data.buttonMobile || 'menu');
|
||||
set.append(toggleLabel, hidden, options);
|
||||
return set;
|
||||
}
|
||||
|
||||
/*
|
||||
* Aus Feldern wird Bedienung: das Symbol über das Auswahlfenster statt einer langen Liste, Farben
|
||||
* mit der Palette der Website wie im Editor, und eine Vorschau des Buttons - normal und beim
|
||||
* Überfahren. Gespeichert wird weiter, was in den Feldern steht; ohne Skript bleiben sie sichtbar.
|
||||
*/
|
||||
function enhance(row) {
|
||||
if (row.dataset.enhanced) return;
|
||||
row.dataset.enhanced = '1';
|
||||
const value = key => row.querySelector(`[data-key="${key}"]`);
|
||||
const changed = input => input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
// Symbol: das Auswahlfenster schreibt in die versteckte Auswahl.
|
||||
const iconSelect = value('icon');
|
||||
if (iconSelect) {
|
||||
const holder = iconSelect.closest('div'); holder.hidden = true;
|
||||
const mount = el('div', 'menu-icon-choice'); holder.before(mount);
|
||||
const draw = () => mount.replaceChildren(renderIconField({
|
||||
label: 'Symbol', value: iconSelect.value || 'none', none: 'Kein Symbol',
|
||||
onChoose: name => { iconSelect.value = name === 'none' ? '' : name; changed(iconSelect); draw(); preview(); mount.querySelector('.icon-field-change')?.focus(); },
|
||||
}));
|
||||
draw();
|
||||
}
|
||||
const toggle = value('button'), options = row.querySelector('[data-button-options]');
|
||||
if (!toggle || !options) return;
|
||||
const show = () => { options.hidden = !toggle.checked; };
|
||||
toggle.addEventListener('change', show);
|
||||
for (const wrap of options.querySelectorAll('[data-color-field]')) {
|
||||
const input = wrap.querySelector('input'); const caption = wrap.querySelector('label');
|
||||
input.type = 'hidden';
|
||||
const control = createColorControl({
|
||||
id: `${input.id}-control`, label: caption.textContent, value: input.value, colors: buttonSchema.colors ?? [],
|
||||
onChange: raw => { input.value = raw; changed(input); preview(); },
|
||||
});
|
||||
caption.htmlFor = `${input.id}-control`;
|
||||
wrap.append(control);
|
||||
}
|
||||
const box = el('div', 'menu-button-preview'); box.setAttribute('aria-hidden', 'true');
|
||||
options.prepend(box);
|
||||
function preview() {
|
||||
if (!buttonSchema.buttons) return;
|
||||
const pick = key => value(key)?.value || '';
|
||||
const values = { ...buttonSchema.buttons };
|
||||
for (const [field, key] of [['buttonShape', 'shape'], ['buttonAppearance', 'appearance'], ['buttonSize', 'size'], ['buttonFill', 'backgroundColor'], ['buttonInk', 'color'], ['buttonHoverFill', 'hoverBackgroundColor'], ['buttonHoverInk', 'hoverColor']]) {
|
||||
if (pick(field)) values[key] = pick(field);
|
||||
}
|
||||
const icon = pick('icon'), before = pick('iconPosition') !== 'after', only = value('iconOnly')?.checked && icon;
|
||||
const samples = [['Normal', false], ['Beim Überfahren', true]].map(([title, hover]) => {
|
||||
const state = el('div', 'button-state'); state.append(el('span', 'field-help', title));
|
||||
const sample = buttonSample(only ? '\u200b' : (value('label')?.value || 'Button'), values, buttonSchema, hover);
|
||||
if (icon) {
|
||||
const holder = el('span', 'button-icon'); holder.append(iconSvg(icon));
|
||||
const color = pick('buttonIconColor');
|
||||
if (color) holder.style.color = color.startsWith('@') ? (buttonSchema.colors ?? []).find(entry => `@${entry.key}` === color)?.value || '' : color;
|
||||
if (before) sample.prepend(holder); else sample.append(holder);
|
||||
}
|
||||
state.append(sample); return state;
|
||||
});
|
||||
box.replaceChildren(...samples);
|
||||
}
|
||||
row.addEventListener('input', preview);
|
||||
row.addEventListener('change', preview);
|
||||
show(); preview();
|
||||
}
|
||||
list.querySelectorAll('.menu-entry').forEach(enhance);
|
||||
|
||||
function restore(value) {
|
||||
form.elements.name.value = value.name; list.replaceChildren(...value.rows.map(createRow)); refresh(); last = state();
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ export function setupPicker(picker, onSelect = () => {}) {
|
||||
input.removeAttribute('aria-activedescendant'); current = -1;
|
||||
};
|
||||
const select = item => {
|
||||
value.value = item?.id ?? '';
|
||||
// Most fields store the page id; a setting that keeps an address asks for the path instead.
|
||||
value.value = item?.[picker.dataset.value || 'id'] ?? '';
|
||||
input.value = item ? `${item.title} (/${item.path === 'home' ? '' : item.path})` : '';
|
||||
input.setCustomValidity(''); status.textContent = item ? 'Ausgewählt.' : '';
|
||||
close(); value.dispatchEvent(new Event('change', { bubbles: true })); onSelect(item);
|
||||
@@ -51,7 +52,11 @@ export function setupPicker(picker, onSelect = () => {}) {
|
||||
};
|
||||
input.addEventListener('focus', () => search(''));
|
||||
input.addEventListener('input', () => {
|
||||
value.value = ''; input.setCustomValidity(input.value && !picker.closest('.menu-add') ? 'Bitte einen Suchtreffer auswählen oder die Suche leeren.' : '');
|
||||
// Ein freies Feld (etwa das Ziel einer Weiterleitung) nimmt auch einen Pfad, der keine Seite
|
||||
// ist; gewählt wird trotzdem am bequemsten aus der Suche.
|
||||
const free = picker.dataset.free === '1';
|
||||
value.value = free ? input.value.trim().replace(/^\/+/, '') : '';
|
||||
input.setCustomValidity(input.value && !free && !picker.closest('.menu-add') ? 'Bitte einen Suchtreffer auswählen oder die Suche leeren.' : '');
|
||||
value.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
controller?.abort(); clearTimeout(timer); timer = setTimeout(() => search(input.value), 180);
|
||||
});
|
||||
@@ -65,3 +70,30 @@ export function setupPicker(picker, onSelect = () => {}) {
|
||||
}
|
||||
|
||||
document.querySelectorAll('.content-picker').forEach(picker => setupPicker(picker));
|
||||
|
||||
/**
|
||||
* Eine Seite oder einen Beitrag für ein Linkziel wählen, in einem kleinen Dialog mit derselben
|
||||
* Suche wie in den Menüs. onChoose bekommt den Treffer mit Titel und Pfad.
|
||||
*/
|
||||
export function openPageDialog({ onChoose } = {}) {
|
||||
const opener = document.activeElement;
|
||||
const dialog = document.createElement('dialog'); dialog.className = 'media-dialog page-dialog';
|
||||
dialog.setAttribute('aria-labelledby', 'page-dialog-title');
|
||||
const source = document.querySelector('meta[name="admin-base"]').content + '/api/content-choices';
|
||||
dialog.innerHTML = `<div class="media-heading"><div><p class="eyebrow">LINKZIEL</p><h2 id="page-dialog-title">Seite wählen</h2></div><button type="button" class="secondary" data-close>Schließen</button></div>
|
||||
<div class="content-picker">
|
||||
<label for="page-dialog-search">Seite oder Beitrag suchen</label>
|
||||
<div class="picker-input"><input id="page-dialog-search" type="search" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="page-dialog-results" placeholder="Titel oder Adresse …"><button type="button" class="subtle picker-clear" aria-label="Suche leeren">✕</button></div>
|
||||
<input type="hidden" class="picker-value">
|
||||
<div id="page-dialog-results" class="picker-results" role="listbox" aria-label="Suchergebnisse" hidden></div>
|
||||
<p class="picker-status field-help" role="status"></p>
|
||||
</div>`;
|
||||
const picker = dialog.querySelector('.content-picker');
|
||||
picker.dataset.source = source; picker.classList.add('menu-add');
|
||||
document.body.append(dialog);
|
||||
setupPicker(picker, item => { if (item) { onChoose?.(item); dialog.close(); } });
|
||||
dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close());
|
||||
dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); });
|
||||
dialog.showModal();
|
||||
dialog.querySelector('#page-dialog-search').focus();
|
||||
}
|
||||
@@ -22,4 +22,4 @@
|
||||
.editor-dragging .editor-empty{background:#e6f1e9;border-color:#467858}
|
||||
.editor-add-content{min-height:0;margin-top:12px;padding:10px;border-width:1px;background:#f3f8f4}
|
||||
.block-accordion.editor-container>.editor-add-content{margin-top:16px}
|
||||
@media(max-width:650px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}}
|
||||
@media(max-width:767px){.editor-empty{padding:16px 10px}.editor-empty button{min-height:44px}}
|
||||
@@ -9,6 +9,8 @@
|
||||
* the alternative would be a selection engine of our own. The formatting it produces is normalised
|
||||
* on the server anyway, so a browser quirk cannot store something unexpected.
|
||||
*/
|
||||
import { openPageDialog } from './page-picker.js';
|
||||
import { openDocuments } from './media.js';
|
||||
|
||||
/** Mirrors RichText::ALLOWED. Only used for the hint text and for cleaning up on paste. */
|
||||
export const ALLOWED_TAGS = ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'code', 'mark', 'sup', 'sub', 'ul', 'ol', 'li', 'a'];
|
||||
@@ -39,7 +41,7 @@ function button(label, title, onClick, className = 'rt-button') {
|
||||
* Builds the control. `apply` is called with the markup whenever it changes; the caller decides
|
||||
* what to do with it (history entry, preview refresh).
|
||||
*/
|
||||
export function createRichText(value, apply, { label = 'Text' } = {}) {
|
||||
export function createRichText(value, apply, { label = 'Text', plain = false } = {}) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'rich-text';
|
||||
|
||||
@@ -55,13 +57,16 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
|
||||
surface.setAttribute('role', 'textbox');
|
||||
surface.setAttribute('aria-multiline', 'true');
|
||||
surface.setAttribute('aria-label', label);
|
||||
surface.innerHTML = value ?? '';
|
||||
// Unter Schema v12 ist der Text Klartext: "Preis <b>5 Euro</b>" ist dann eine Zeichenfolge, kein
|
||||
// Markup, und darf beim Oeffnen nicht als HTML gelesen werden.
|
||||
if (plain) surface.textContent = value ?? '';
|
||||
else surface.innerHTML = value ?? '';
|
||||
|
||||
const source = document.createElement('textarea');
|
||||
source.className = 'rt-source';
|
||||
source.hidden = true;
|
||||
source.setAttribute('aria-label', `${label} als HTML`);
|
||||
source.value = value ?? '';
|
||||
source.value = plain ? surface.innerHTML : (value ?? '');
|
||||
|
||||
const push = markup => { source.value = markup; apply(markup); };
|
||||
|
||||
@@ -113,7 +118,12 @@ export function createRichText(value, apply, { label = 'Text' } = {}) {
|
||||
if ('Enter' === event.key) { event.preventDefault(); confirmLink(); }
|
||||
if ('Escape' === event.key) { event.preventDefault(); linkRow.hidden = true; surface.focus(); }
|
||||
});
|
||||
linkRow.append(linkInput, newTabLabel, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
|
||||
// Eine eigene Seite oder ein PDF wählt man, statt die Adresse zu kennen.
|
||||
const fill = value => { linkInput.value = value; linkInput.focus(); };
|
||||
linkRow.append(linkInput,
|
||||
button('Seite …', 'Seite oder Beitrag als Linkziel wählen', () => openPageDialog({ onChoose: item => fill(item.path === 'home' ? '/' : `/${item.path}`) }), 'text-button'),
|
||||
button('PDF …', 'PDF aus der Mediathek als Linkziel wählen', () => openDocuments({ onChoose: item => fill(item.src) }), 'text-button'),
|
||||
newTabLabel, button('Übernehmen', 'Linkziel übernehmen', confirmLink, 'text-button'));
|
||||
|
||||
bar.append(button('🔗', 'Link setzen oder entfernen', () => {
|
||||
const selection = window.getSelection();
|
||||
|
||||
@@ -8,12 +8,27 @@
|
||||
*/
|
||||
export function initScrolled(doc = document) {
|
||||
const view = doc.defaultView;
|
||||
const root = doc.documentElement;
|
||||
// Der Nach-oben-Knopf blendete sich ueber eine Scroll-Animation ein. Browser, die
|
||||
// `animation-timeline: scroll()` zwar kennen, aber nicht ausfuehren, liessen ihn im ersten
|
||||
// Bild der Animation stehen - unsichtbar, dauerhaft. Deshalb entscheidet jetzt eine Klasse.
|
||||
// Ohne dieses Skript bleibt der Knopf sichtbar: erreichbar ist besser als unsichtbar.
|
||||
const after = parseInt(getComputedStyle(root).getPropertyValue('--kant-top-after'), 10);
|
||||
const schwelle = Number.isFinite(after) && after > 0 ? after : 400;
|
||||
let queued = false;
|
||||
const sync = () => { queued = false; doc.documentElement.classList.toggle('is-scrolled', view.scrollY > 40); };
|
||||
const sync = () => {
|
||||
queued = false;
|
||||
root.classList.toggle('is-scrolled', view.scrollY > 40);
|
||||
root.classList.toggle('kant-top-shown', view.scrollY > schwelle);
|
||||
};
|
||||
const onScroll = () => { if (!queued) { queued = true; view.requestAnimationFrame(sync); } };
|
||||
view.addEventListener('scroll', onScroll, { passive: true });
|
||||
root.classList.add('kant-top-armed');
|
||||
sync();
|
||||
return () => { view.removeEventListener('scroll', onScroll); doc.documentElement.classList.remove('is-scrolled'); };
|
||||
return () => {
|
||||
view.removeEventListener('scroll', onScroll);
|
||||
root.classList.remove('is-scrolled', 'kant-top-armed', 'kant-top-shown');
|
||||
};
|
||||
}
|
||||
|
||||
if (!document.querySelector('#editor')) initScrolled();
|
||||
+268
-44
@@ -5,13 +5,13 @@ h1,h2,h3,h4,h5,h6,p,a{overflow-wrap:break-word}
|
||||
h1,h2,h3,h4,h5,h6{hyphens:manual}
|
||||
p{hyphens:auto}
|
||||
.visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}.block-image{margin:0;max-width:100%}.block-image img{max-width:100%;height:auto}
|
||||
:root{color-scheme:light;--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
|
||||
:root{color-scheme:var(--k-scheme,light);--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;--error:var(--k-error,#8b2f22);--error-bg:var(--k-error-bg,#fff0eb);--error-line:var(--k-error-line,#e2b2a7);font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
|
||||
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}
|
||||
/* Über dem Inhalt: der Kopf nimmt keinen Platz ein, die erste Sektion beginnt unter ihm. Farbe
|
||||
und Schatten kommen aus der Gestaltung des Kopfbereichs, auch die für den gescrollten Zustand. */
|
||||
.site-header.is-overlay{position:absolute;top:0;left:0;right:0;z-index:40;border-bottom:0;background:transparent;box-shadow:none}
|
||||
.site-header.is-overlay.is-sticky{position:fixed}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
|
||||
.site-header.is-overlay.is-sticky{position:fixed}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:none;font-weight:800}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:var(--paper);padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||
@media(max-width:767px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
|
||||
|
||||
/* Menus. Submenus open with details/summary, so the navigation works without JavaScript. */
|
||||
.menu-list{list-style:none;display:flex;flex-wrap:wrap;align-items:center;gap:4px 22px;margin:0;padding:0}
|
||||
@@ -27,7 +27,7 @@ p{hyphens:auto}
|
||||
/* Posts */
|
||||
.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px}.post-list-karten{grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}.post-list-liste{gap:18px}.post-list-liste .post-card{flex-direction:row;align-items:stretch}.post-list-liste .post-image{flex:0 0 34%;max-width:300px}.post-list-liste .post-image img{height:100%;aspect-ratio:auto;min-height:170px}.post-list-liste .post-body{flex:1 1 auto;align-content:center;padding:22px 26px}.post-list-wechsel{gap:0;counter-reset:beitrag}.post-list-wechsel .post-card{position:relative;counter-increment:beitrag;display:grid;grid-template-columns:1fr 1fr;align-items:center;gap:0 56px;padding:38px 0;background:transparent;border:0;border-radius:0;overflow:visible}.post-list-wechsel .post-card + .post-card{border-top:1px solid var(--line)}.post-list-wechsel .post-image{border-radius:20px;overflow:hidden;box-shadow:0 18px 46px -22px color-mix(in srgb,var(--ink) 55%,transparent)}.post-list-wechsel .post-image img{aspect-ratio:16/10;border-radius:20px}.post-list-wechsel .post-body{padding:0;display:grid;gap:12px;align-content:center}.post-list-wechsel .post-body::before{content:counter(beitrag,decimal-leading-zero);font-size:13px;font-weight:800;letter-spacing:2px;color:var(--accent)}.post-list-wechsel .post-card h3{font-size:28px;line-height:1.18;letter-spacing:-.5px}.post-list-wechsel .post-excerpt{margin:0;max-width:46ch}.post-list-wechsel .post-meta,.post-list-wechsel .post-terms{margin:0}.post-list-wechsel .post-more{display:inline-flex;align-items:center;gap:8px;margin-top:4px;font-size:15px;font-weight:700;color:var(--accent);text-decoration:none;width:fit-content}
|
||||
.post-more::after{content:'→';transition:transform .2s}
|
||||
.post-more:hover::after{transform:translateX(4px)}.post-list-wechsel .post-more::after{content:'\2192'}.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}.post-list-wechsel .post-card:nth-child(even) .post-body{order:1}@media(max-width:760px){.post-list-wechsel .post-card{grid-template-columns:1fr;gap:18px;padding:26px 0}.post-list-wechsel .post-card:nth-child(even) .post-image{order:0}.post-list-wechsel .post-card:nth-child(even) .post-body{order:0}.post-list-wechsel .post-card h3{font-size:22px}}.post-list-kompakt{gap:0}.post-list-kompakt .post-card{flex-direction:row;align-items:baseline;justify-content:space-between;gap:18px;background:transparent;border:0;border-bottom:1px solid var(--line);border-radius:0;overflow:visible}.post-list-kompakt .post-body{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px;padding:16px 2px}.post-list-kompakt .post-meta,.post-list-kompakt .post-terms{margin:0}.post-list-kompakt .post-excerpt{flex:1 1 100%;margin:0}
|
||||
.post-more:hover::after{transform:translateX(4px)}.post-list-wechsel .post-more::after{content:'\2192'}.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}.post-list-wechsel .post-card:nth-child(even) .post-body{order:1}@media(max-width:767px){.post-list-wechsel .post-card{grid-template-columns:1fr;gap:18px;padding:26px 0}.post-list-wechsel .post-card:nth-child(even) .post-image{order:0}.post-list-wechsel .post-card:nth-child(even) .post-body{order:0}.post-list-wechsel .post-card h3{font-size:22px}}.post-list-kompakt{gap:0}.post-list-kompakt .post-card{flex-direction:row;align-items:baseline;justify-content:space-between;gap:18px;background:transparent;border:0;border-bottom:1px solid var(--line);border-radius:0;overflow:visible}.post-list-kompakt .post-body{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px;padding:16px 2px}.post-list-kompakt .post-meta,.post-list-kompakt .post-terms{margin:0}.post-list-kompakt .post-excerpt{flex:1 1 100%;margin:0}
|
||||
.post-card{display:flex;flex-direction:column;background:var(--paper);border:1px solid var(--line);border-radius:18px;overflow:hidden}.post-card>.post-body{display:grid;gap:10px;padding:20px 22px 22px}
|
||||
.post-image{display:block;position:relative;overflow:hidden;text-decoration:none}.post-view{position:absolute;inset:0;display:grid;place-items:center;background:color-mix(in srgb,var(--ink) 55%,transparent);color:#fff;font-weight:700;font-size:15px;letter-spacing:.4px;opacity:0}.post-card:hover .post-view,.post-card:focus-within .post-view{opacity:1}.post-image img{width:100%;height:auto;aspect-ratio:16/10;object-fit:cover;border-radius:0;display:block;background:var(--ink)}
|
||||
.kant h1 mark,.kant h2 mark,.kant h3 mark{background:transparent;color:var(--accent);padding:0}
|
||||
@@ -44,6 +44,11 @@ p{hyphens:auto}
|
||||
|
||||
/* Blocks introduced with schema v6. No JavaScript: the accordion is details/summary. */
|
||||
.block-icon{flex-shrink:0;display:block}
|
||||
/* Der Pfeil am Button und in der Beitragsnavigation ist dasselbe Symbol wie ueberall sonst -
|
||||
frueher stand dort ein Schriftzeichen, das je nach Schrift anders aussah. Er waechst mit der
|
||||
Schrift des Knopfs, statt eine eigene Groesse zu haben. */
|
||||
.button-icon,.nav-arrow{display:inline-flex;align-items:center}.button-icon{justify-content:center;flex:none;transition:rotate .25s ease,transform .25s ease,background-color .2s,color .2s,max-width .3s ease,opacity .3s ease,margin .3s ease}
|
||||
.nav-arrow>svg{width:1.15em;height:1.15em}.cms-button .button-icon>svg{width:var(--icon-size,1.15em);height:var(--icon-size,1.15em)}.button.cms-button{gap:var(--icon-gap,.65em)}.cms-button.icon-motion-slide:is(:hover,:focus-visible) .button-icon{transform:translateX(.3em)}.cms-button.icon-start.icon-motion-slide:is(:hover,:focus-visible) .button-icon{transform:translateX(-.3em)}.button.cms-button.icon-motion-reveal{gap:0}.cms-button.icon-motion-reveal .button-icon{max-width:0;opacity:0;overflow:hidden}.cms-button.icon-motion-reveal:is(:hover,:focus-visible) .button-icon{max-width:4em;opacity:1;margin-inline-start:var(--icon-gap,.65em)}.cms-button.icon-start.icon-motion-reveal:is(:hover,:focus-visible) .button-icon{margin-inline:0 var(--icon-gap,.65em)}.button.cms-button.icon-only{gap:0;padding:var(--b-py,var(--kb-py,15px))}.cms-button.icon-only .button-icon{min-inline-size:1lh}.cms-button.icon-only .button-label{width:0;overflow:hidden;white-space:nowrap}@media (prefers-reduced-motion:reduce){html.motion-respect .cms-button .button-icon{transition:none}}
|
||||
.block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px}
|
||||
.block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px}
|
||||
/* Gestapelt brauchen Karten den Abstand; nebeneinander in einer Zeile macht er die zweite bis
|
||||
@@ -64,7 +69,7 @@ p{hyphens:auto}
|
||||
.quote-rating{margin:0;letter-spacing:3px}
|
||||
.block-logos{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));align-items:center;gap:24px}
|
||||
.block-logos img{max-height:52px;width:auto;margin-inline:auto;display:block}
|
||||
.kant .block-cta .button{background:var(--accent);color:#fff;border-color:var(--accent)}.kant .block-cta .button:hover{background:color-mix(in srgb,var(--accent) 86%,#000)}.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px}
|
||||
.kant .block-cta .button{background:var(--accent);color:var(--paper);border-color:var(--accent)}.kant .block-cta .button:hover{background:color-mix(in srgb,var(--accent) 86%,var(--ink))}.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px}
|
||||
.kant .block-cta h2{margin:0 0 6px;font-size:26px;letter-spacing:-.6px}
|
||||
.block-cta p{margin:0;line-height:1.6}
|
||||
.block-contact{display:grid;gap:14px;font-style:normal}
|
||||
@@ -96,22 +101,73 @@ p{hyphens:auto}
|
||||
/* Forms. Server rendered, no JavaScript; every message sits at its field. */
|
||||
.block-form{display:grid;gap:20px;max-width:640px}
|
||||
.block-form>.cms-button{justify-self:start}
|
||||
/* Ausrichtung eines Buttons (Design-Token „align"): ein inline-flex-Element zentriert sich nicht
|
||||
über margin:auto, darum hier als Block mit eigener Breite - in Spalten, Zeilen und Rastern. */
|
||||
.kant .cms-button.d-desktop-align-left{justify-self:start;margin-inline:0}
|
||||
.kant .cms-button.d-desktop-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center}
|
||||
.kant .cms-button.d-desktop-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end}
|
||||
@media(max-width:1024px){.kant .cms-button.d-tablet-align-left{display:inline-flex;width:auto;justify-self:start;margin-inline:0}.kant .cms-button.d-tablet-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center}.kant .cms-button.d-tablet-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end}}
|
||||
@media(max-width:767px){.kant .cms-button.d-mobile-align-left{display:inline-flex;width:auto;justify-self:start;margin-inline:0}.kant .cms-button.d-mobile-align-center{display:flex;width:fit-content;margin-inline:auto;justify-self:center}.kant .cms-button.d-mobile-align-right{display:flex;width:fit-content;margin-inline:auto 0;justify-self:end}}
|
||||
.form-field{display:grid;gap:7px}
|
||||
.form-field label{font-weight:600;font-size:14px}
|
||||
.form-field input,.form-field textarea,.form-field select{width:100%;padding:12px 13px;border:1px solid currentColor;border-radius:8px;background:#fff;color:inherit;font:inherit;min-height:46px}
|
||||
.form-field input,.form-field textarea,.form-field select{width:100%;padding:12px 13px;border:1px solid var(--form-border,currentColor);border-radius:8px;background:var(--form-bg,#fff);color:inherit;font:inherit;min-height:46px}
|
||||
.form-field textarea{min-height:140px;line-height:1.6;resize:vertical}
|
||||
.has-error input,.has-error textarea,.has-error select{border-width:2px;border-color:#8b2f22}
|
||||
.field-error{margin:0;font-size:13px;font-weight:600;color:#8b2f22}
|
||||
.has-error input,.has-error textarea,.has-error select{border-width:2px;border-color:var(--error)}
|
||||
.field-error{margin:0;font-size:13px;font-weight:600;color:var(--error)}
|
||||
.field-note{margin:0;font-size:13px;opacity:.8}
|
||||
.form-error-summary{margin:0;padding:14px 18px;border-radius:8px;background:#fff0eb;color:#8b2f22;border:1px solid #e2b2a7;font-weight:600}
|
||||
.form-error-summary{margin:0;padding:14px 18px;border-radius:8px;background:var(--error-bg);color:var(--error);border:1px solid var(--error-line);font-weight:600}
|
||||
.form-success{margin:0;padding:16px 20px;border-radius:8px;background:color-mix(in srgb,var(--accent) 10%,var(--paper));color:var(--accent);border:1px solid color-mix(in srgb,var(--accent) 35%,var(--paper));font-weight:600}
|
||||
.form-missing{margin:0;padding:14px 18px;border:1px dashed currentColor;border-radius:8px}
|
||||
.check-row{display:flex;align-items:flex-start;gap:11px}
|
||||
.check-row input{width:20px;min-height:20px;flex-shrink:0;margin-top:3px}
|
||||
.check-row label{font-weight:400;line-height:1.55}
|
||||
.required-mark{color:#8b2f22}
|
||||
.required-mark{color:var(--error)}
|
||||
/* Off screen rather than display:none so a filled value still proves automation. */
|
||||
.form-trap{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}
|
||||
/* Schritte, Bedingungen, Zusammenfassung. Ohne Skript: Abschnitte untereinander, alles sichtbar. */
|
||||
.form-step{display:grid;gap:20px;margin:0;padding:0;border:0;min-width:0}
|
||||
/* Ein Schritt und der Absendeknopf sind Raster bzw. inline-flex - das hidden-Attribut muss trotzdem gelten. */
|
||||
.block-form [hidden]{display:none!important}
|
||||
.form-step-title{padding:0;margin:0;font-weight:600;font-size:18px;line-height:1.3}
|
||||
.form-step-title:focus{outline:none}
|
||||
.form-step-intro{margin:-8px 0 0;font-size:14px;opacity:.85}
|
||||
.form-choice{display:grid;gap:10px;margin:0;padding:0;border:0;min-width:0}
|
||||
.form-choice legend{padding:0;margin-bottom:2px;font-weight:600;font-size:14px}
|
||||
.form-choice .check-row{align-items:center}
|
||||
.form-choice .check-row input{margin-top:0}
|
||||
/* Bildauswahl: jede Option eine Karte, das Kästchen bleibt für Tastatur und Vorlesesoftware. */
|
||||
.form-choice-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:12px}
|
||||
.form-choice-card{position:relative}
|
||||
.form-choice-card input{position:absolute;top:10px;left:10px;width:20px;height:20px;margin:0;z-index:1}
|
||||
.form-choice-card label{display:grid;gap:8px;padding:8px;border:2px solid var(--form-border,currentColor);border-radius:10px;background:var(--form-bg,#fff);cursor:pointer;font-weight:600;font-size:14px;text-align:center;transition:border-color .2s,box-shadow .2s}
|
||||
.form-choice-card label:hover{border-color:var(--kb-fill,var(--accent))}
|
||||
.form-choice-card input:checked+label{border-color:var(--kb-fill,var(--accent));box-shadow:0 0 0 2px var(--kb-fill,var(--accent))}
|
||||
.form-choice-card input:focus-visible+label{outline:3px solid var(--kb-fill,var(--accent));outline-offset:3px}
|
||||
.form-choice-picture{display:block;aspect-ratio:4/3;border-radius:6px;overflow:hidden;background:color-mix(in srgb,currentColor 6%,transparent)}
|
||||
.form-choice-picture img{display:block;width:100%;height:100%;object-fit:cover}
|
||||
.form-choice-text{display:block;padding:0 4px 4px}
|
||||
.form-number{display:flex;align-items:center;gap:10px}
|
||||
.form-number input{flex:1 1 auto;min-width:0}
|
||||
.form-unit{flex:none;font-size:14px;opacity:.8}
|
||||
[data-inactive]{display:none!important}
|
||||
.form-progress{display:flex;flex-wrap:wrap;gap:8px 18px;list-style:none;margin:0;padding:0;font-size:13px}
|
||||
.form-progress-step{display:flex;align-items:center;gap:8px;opacity:.55}
|
||||
.form-progress-step[aria-current]{opacity:1;font-weight:600}
|
||||
.form-progress-step.is-done{opacity:.85}
|
||||
.form-progress-number{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;border:1px solid currentColor;font-size:12px}
|
||||
.form-progress-step[aria-current] .form-progress-number,.form-progress-step.is-done .form-progress-number{background:var(--kb-fill,var(--accent));color:var(--kb-ink,#fff);border-color:transparent}
|
||||
.form-step-nav{display:flex;flex-wrap:wrap;gap:12px;align-items:center}
|
||||
.form-step-nav .form-step-back{--kb-fill:transparent;--kb-ink:currentColor;--kb-hover-fill:transparent;--kb-hover-ink:currentColor;border:1px solid currentColor;background:transparent;color:inherit}
|
||||
.form-summary-body{display:grid;gap:18px}
|
||||
.form-summary-step{padding:16px 18px;border:1px solid var(--form-border,currentColor);border-radius:8px}
|
||||
.form-summary-head{display:flex;justify-content:space-between;align-items:baseline;gap:12px;margin-bottom:8px}
|
||||
.form-summary-title{margin:0;font-size:15px;font-weight:600}
|
||||
.form-summary-change{border:0;padding:0;background:none;color:inherit;font:inherit;font-size:14px;text-decoration:underline;cursor:pointer}
|
||||
.form-summary-list{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.4fr);gap:6px 16px;margin:0;font-size:14px}
|
||||
.form-summary-list dt{font-weight:600}
|
||||
.form-summary-list dd{margin:0;overflow-wrap:anywhere}
|
||||
.form-summary-empty{opacity:.6}
|
||||
@media(max-width:767px){.form-summary-list{grid-template-columns:1fr;gap:2px 0}.form-summary-list dd{margin-bottom:8px}}
|
||||
|
||||
.block-breadcrumb ol{list-style:none;display:flex;flex-wrap:wrap;gap:6px 10px;margin:0;padding:0;font-size:13px}
|
||||
.block-breadcrumb li+li::before{content:'›';margin-right:10px;opacity:.6}
|
||||
@@ -122,35 +178,60 @@ p{hyphens:auto}
|
||||
for light backgrounds. Inside a dark tone they won on specificity, so text in a dark column was
|
||||
unreadable - 1.87:1 in the seeded footer. These two selectors match that specificity and come
|
||||
later, so the dark tone wins where it has to. */
|
||||
.tone-ink .block-column .block-text{color:#dae3da}
|
||||
.tone-ink .block-column .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}
|
||||
|
||||
/* The consent question. Fixed to the bottom, but it never covers the whole page: the content
|
||||
stays readable while the decision is open, because a wall would make "accept" the only way
|
||||
forward. Accepting and refusing are the same button in the same row. */
|
||||
.consent-bar{position:fixed;inset-inline:12px;bottom:12px;z-index:50;max-width:620px;margin-inline:auto;background:var(--paper);color:var(--ink);border:1px solid var(--line);border-radius:14px;padding:20px 22px;box-shadow:0 18px 50px color-mix(in srgb,var(--ink) 15%,transparent);font-size:14px;line-height:1.6}
|
||||
.kant .consent-bar h2{font-size:16px;margin:0 0 8px}
|
||||
.consent-bar p{margin:0 0 14px}
|
||||
forward. Accepting and refusing are the same button in the same row.
|
||||
|
||||
Aussehen aus den Einstellungen: `--kant-consent-*` im Kopf der Seite (Consent::styles), die
|
||||
Anordnung als Klasse. Ohne Einstellung gilt Papier, Tinte und Akzent des Themes. Ein Wert wie
|
||||
`--k-consent` aus der Palette bleibt als Zwischenstufe erhalten. */
|
||||
.consent-bar{position:fixed;inset-inline:16px;bottom:16px;z-index:50;max-width:640px;max-height:calc(100dvh - 24px);overflow:auto;margin-inline:auto;background:var(--kant-consent-bg,var(--paper));color:var(--kant-consent-ink,var(--ink));border:1px solid color-mix(in srgb,currentColor 12%,transparent);border-radius:var(--kant-consent-radius,18px);padding:22px 24px 20px;box-shadow:0 24px 60px color-mix(in srgb,var(--ink) 22%,transparent);font-size:14px;line-height:1.6;overscroll-behavior:contain}
|
||||
/* Ein schmaler Streifen in der Akzentfarbe oben: die Fläche gehört sichtbar zur Website. */
|
||||
.consent-bar::before{content:'';position:absolute;inset:0 0 auto 0;height:4px;background:var(--kant-consent-accent,var(--k-consent,var(--accent)))}
|
||||
.kant .consent-bar h2{display:flex;align-items:center;gap:10px;font-size:17px;line-height:1.3;margin:0 0 8px}
|
||||
.consent-icon{flex:none;color:var(--kant-consent-accent,var(--k-consent,var(--accent)))}
|
||||
.consent-bar p{margin:0 0 16px;color:color-mix(in srgb,currentColor 82%,transparent)}
|
||||
.consent-actions{display:flex;flex-wrap:wrap;gap:10px}
|
||||
.consent-button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:11px 18px;border-radius:8px;border:1px solid var(--k-consent,var(--accent));background:var(--k-consent,var(--accent));color:#fff;font:inherit;font-weight:600;cursor:pointer}
|
||||
.consent-actions .consent-button:last-child{background:var(--paper);color:var(--ink);border-color:var(--line)}
|
||||
.consent-button{display:inline-flex;align-items:center;justify-content:center;flex:1 1 150px;min-height:46px;padding:11px 18px;border-radius:var(--kant-consent-button-radius,10px);border:1.5px solid var(--kant-consent-accent,var(--k-consent,var(--accent)));background:var(--kant-consent-accent,var(--k-consent,var(--accent)));color:var(--kant-consent-accent-ink,#fff);font:inherit;font-weight:600;cursor:pointer;transition:filter .12s,background-color .12s}
|
||||
.consent-decline,.consent-choice{background:transparent;color:inherit;border-color:color-mix(in srgb,currentColor 28%,transparent)}
|
||||
.consent-button:hover{filter:brightness(.94)}
|
||||
.consent-button:focus-visible,.consent-bar a:focus-visible,.consent-bar summary:focus-visible,.consent-bar input:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
|
||||
.consent-details{margin-top:14px}
|
||||
.consent-details>summary{cursor:pointer;font-size:13px;font-weight:600;padding:4px 0}
|
||||
.consent-decline:hover,.consent-choice:hover{filter:none;background:color-mix(in srgb,currentColor 7%,transparent)}
|
||||
.consent-button:focus-visible,.consent-bar a:focus-visible,.consent-bar summary:focus-visible,.consent-bar input:focus-visible{outline:3px solid var(--kant-consent-accent,var(--k-consent,var(--accent)));outline-offset:2px}
|
||||
/* Fusszeile der Frage: Einzelwahl links, Rechtslinks rechts, auf einer Linie. Aufgeklappt
|
||||
nimmt die Einzelwahl die volle Breite und die Links rutschen rechtsbuendig darunter. */
|
||||
.consent-foot{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:baseline;gap:8px 20px;margin-top:14px}
|
||||
.consent-details{flex:0 1 auto;min-width:0}
|
||||
.consent-details[open]{flex-basis:100%}
|
||||
.consent-details>summary{cursor:pointer;font-size:13px;font-weight:600;padding:6px 0;list-style:none;display:inline-flex;align-items:center;gap:6px}
|
||||
.consent-details>summary::-webkit-details-marker{display:none}
|
||||
.consent-details>summary::after{content:'';width:7px;height:7px;border-right:1.6px solid currentColor;border-bottom:1.6px solid currentColor;transform:translateY(-2px) rotate(45deg);transition:transform .15s}
|
||||
.consent-details[open]>summary::after{transform:translateY(2px) rotate(-135deg)}
|
||||
.consent-details>summary:hover{text-decoration:underline}
|
||||
.consent-groups{list-style:none;margin:10px 0 0;padding:0;display:grid;gap:10px}
|
||||
.consent-groups label{display:flex;gap:10px;align-items:flex-start;font-size:13px;line-height:1.5}
|
||||
.consent-groups input{margin-top:3px;width:18px;height:18px;flex-shrink:0}
|
||||
.consent-choice{margin-top:14px;background:var(--paper);color:var(--ink);border-color:var(--line)}
|
||||
.consent-links{margin:14px 0 0;font-size:12px}
|
||||
.consent-links a{color:var(--accent)}
|
||||
.consent-groups input{margin-top:3px;width:18px;height:18px;flex-shrink:0;accent-color:var(--kant-consent-accent,var(--k-consent,var(--accent)))}
|
||||
.consent-choice{margin-top:14px;flex:none;width:auto}
|
||||
/* Eigener Selektor mit Element, sonst gewinnt `.consent-bar p` und nimmt den Rand rechts weg. */
|
||||
.consent-bar p.consent-links{margin:0 0 0 auto;display:flex;gap:14px;font-size:12px;color:color-mix(in srgb,currentColor 72%,transparent)}
|
||||
.consent-links a{color:inherit;text-decoration:underline;text-underline-offset:2px}
|
||||
.consent-links a:hover{color:var(--kant-consent-accent,var(--k-consent,var(--accent)))}
|
||||
/* Leiste: über die ganze Breite am unteren Rand, auf breiten Bildschirmen Text links und Knöpfe
|
||||
rechts. Ecke: eine kleine Karte rechts unten, die den Inhalt kaum verdeckt. */
|
||||
.consent-layout-leiste{inset-inline:0;bottom:0;max-width:none;border-radius:var(--kant-consent-radius,18px) var(--kant-consent-radius,18px) 0 0;border-inline:0;border-bottom:0;padding-inline:max(24px,calc((100% - 1180px) / 2))}
|
||||
@media (min-width:900px){.consent-layout-leiste{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 40px;align-items:center}.consent-layout-leiste .consent-body p{margin-bottom:0}.consent-layout-leiste .consent-form{width:360px;max-width:100%}}
|
||||
.consent-layout-ecke{inset-inline:auto 16px;max-width:400px;margin:0}
|
||||
/* While the question is open the bar is fixed at the bottom, so the page has to end above it -
|
||||
otherwise it covers the last element, and an embed's own consent button sat right underneath.
|
||||
Found by the browser test, which could not click it. */
|
||||
body.consent-open{padding-bottom:260px}
|
||||
Found by the browser test, which could not click it. Auf schmalen Bildschirmen ist die Leiste
|
||||
mit Standardtext bis 290 px hoch; 320 px lassen ihr Luft, ohne einen Bildschirm zu verschenken. */
|
||||
body.consent-open{padding-bottom:320px}
|
||||
@media (min-width:621px){body.consent-open{padding-bottom:270px}}
|
||||
@media (min-width:900px){body.consent-open{padding-bottom:200px}}
|
||||
.consent-page main{max-width:680px;margin-inline:auto;padding:40px 20px}
|
||||
.consent-page .consent-bar{position:static;inset:auto;box-shadow:none;max-width:none;margin-top:24px}
|
||||
@media (max-width:620px){.consent-bar{inset-inline:0;bottom:0;border-radius:14px 14px 0 0;padding:16px}.consent-actions .consent-button{flex:1 1 140px}}
|
||||
.consent-page .consent-bar{position:static;inset:auto;box-shadow:none;max-width:none;max-height:none;margin:24px 0 0;display:block}
|
||||
@media (max-width:620px){.consent-bar,.consent-layout-ecke{inset-inline:0;bottom:0;max-width:none;border-radius:var(--kant-consent-radius,18px) var(--kant-consent-radius,18px) 0 0;border-inline:0;border-bottom:0;padding:18px 18px 16px}.consent-layout-leiste{padding-inline:18px}}
|
||||
|
||||
/* An embedded third party, and what stands in its place until it may load. */
|
||||
.block-embed iframe{display:block;width:100%;border:0;border-radius:10px;background:color-mix(in srgb,var(--ink) 8%,var(--paper))}
|
||||
@@ -158,6 +239,11 @@ body.consent-open{padding-bottom:260px}
|
||||
.embed-title{margin:0 0 8px;font-weight:600}
|
||||
.embed-hint{margin:0 0 10px;font-size:14px;line-height:1.6;color:var(--muted)}
|
||||
.embed-provider{margin:0 0 16px;font-size:13px;color:var(--muted)}
|
||||
/* Eigenes HTML: nur so viel Regel, dass fremdes Markup die Spalte nicht sprengt. Alles
|
||||
Weitere bestimmt der Code selbst - das ist der Zweck des Bausteins. */
|
||||
.block-html{max-width:100%}
|
||||
.block-html img,.block-html video,.block-html table{max-width:100%}
|
||||
.block-html table{border-collapse:collapse}
|
||||
|
||||
/* Header and footer hold block documents of their own (ADR 026). They are therefore ordinary
|
||||
block containers - as flex containers they shrank the section inside them until a single word
|
||||
@@ -178,6 +264,12 @@ body.consent-open{padding-bottom:260px}
|
||||
/* A single icon, sized by class because inline styles are forbidden by the policy. */
|
||||
.block-icon{display:inline-flex;align-items:center;justify-content:center;color:var(--accent);line-height:0}
|
||||
.block-icon svg{width:100%;height:100%;display:block}
|
||||
/* Das eingebettete SVG traegt dieselbe Klasse wie seine Huelle und setzte deshalb die
|
||||
Akzentfarbe erneut - eine am Symbolblock eingestellte Farbe kam nie an. */
|
||||
.block-icon .block-icon{color:inherit}
|
||||
/* Der Pfeil im Knopf ist dasselbe Symbol und nahm deshalb die Akzentfarbe statt der Schriftfarbe
|
||||
des Knopfs: grün auf Rot, auch beim Überfahren (Hüfner, 06.10.2026). */
|
||||
.button-icon .block-icon{color:inherit}
|
||||
.icon-size-12{width:12px;height:12px}.icon-size-16{width:16px;height:16px}.icon-size-20{width:20px;height:20px}
|
||||
.icon-size-24{width:24px;height:24px}.icon-size-32{width:32px;height:32px}.icon-size-40{width:40px;height:40px}
|
||||
.icon-size-48{width:48px;height:48px}.icon-size-56{width:56px;height:56px}.icon-size-64{width:64px;height:64px}
|
||||
@@ -203,8 +295,32 @@ body.consent-open{padding-bottom:260px}
|
||||
.slider-dots{position:relative}
|
||||
@supports selector(::scroll-marker){.slider-dots:not(.slider-controlled){scroll-marker-group:after;scroll-behavior:smooth;padding-bottom:0;scrollbar-width:none;scrollbar-gutter:auto}.slider-dots:not(.slider-controlled)::-webkit-scrollbar{display:none}.slider-dots:not(.slider-controlled)>*::scroll-marker{content:'';width:12px;height:12px;border-radius:50%;background:color-mix(in srgb,var(--slider-dot,currentColor) 30%,transparent);margin:0 6px;cursor:pointer}.slider-dots>*::scroll-marker:target-current{background:var(--slider-dot,var(--accent))}.slider-dots::scroll-marker-group{display:flex;justify-content:center;align-items:center;padding:14px 0 2px}}
|
||||
.slider-controlled{scrollbar-width:none;scrollbar-gutter:auto}.slider-controlled::-webkit-scrollbar{display:none}
|
||||
.slider-controls{display:flex;align-items:center;justify-content:center;flex-wrap:wrap;gap:8px;min-height:48px;margin-top:8px;padding:4px;font:14px/1.4 system-ui,sans-serif}.slider-controls button{display:inline-flex;align-items:center;justify-content:center;min-width:44px;min-height:40px;padding:8px 12px;border:1px solid color-mix(in srgb,var(--slider-ink,#193c2d) 35%,transparent);border-radius:7px;background:var(--slider-bg,#fff);color:var(--slider-ink,#193c2d);cursor:pointer;font:inherit}.slider-controls button:focus-visible{outline:3px solid #b55b12;outline-offset:3px}.slider-controls button:hover{background:color-mix(in srgb,var(--slider-bg,#fff) 85%,var(--slider-ink,#193c2d))}.slider-controls button:disabled{opacity:.6;cursor:default}.slider-position{font-variant-numeric:tabular-nums;min-width:44px;text-align:center;color:var(--slider-ink,inherit)}.slider-control-dots{display:flex;flex-wrap:wrap;gap:3px}.slider-control-dots button{min-width:32px;padding:0;border:0;background:transparent;color:var(--slider-dot,var(--slider-ink,inherit));font-size:0}.slider-control-dots button::before{content:'';width:10px;height:10px;border-radius:50%;border:2px solid currentColor;background:transparent;transition:background-color .2s}.slider-control-dots button[aria-pressed=true]::before{background:currentColor}.slider-control-dots button:hover::before{background:color-mix(in srgb,currentColor 50%,transparent)}.slider-controls[hidden]{display:none}
|
||||
@media(max-width:650px){.slider-controls button{min-height:44px;min-width:44px}}
|
||||
/* Die Steuerung liegt auf dem Bild, wie beim Hero-Slider von kant-it.de: runde Pfeile links und
|
||||
rechts, Punkte unten rechts (der aktive als Strich in der Akzentfarbe), Pause klein unten links,
|
||||
kein Zähler. Farben kommen aus der Gestaltung des Sliders (--slider-*). */
|
||||
.slider-shell{position:relative}
|
||||
.slider-controls{position:absolute;inset:0;margin:0;padding:0;pointer-events:none;font:14px/1 system-ui,sans-serif}
|
||||
.slider-controls button{pointer-events:auto;cursor:pointer;font:inherit;padding:0;border:0;background:transparent;color:inherit}
|
||||
.slider-controls button:focus-visible{outline:3px solid var(--slider-dot-active,var(--accent));outline-offset:3px}
|
||||
.slider-controls .slider-prev,.slider-controls .slider-next{position:absolute;top:50%;transform:translateY(-50%);width:50px;height:50px;border-radius:50%;background:var(--slider-bg,rgba(255,255,255,.9));color:var(--slider-ink,var(--accent));display:flex;align-items:center;justify-content:center;font-size:0;box-shadow:0 6px 18px rgba(0,0,0,.18);transition:background-color .2s,color .2s,transform .2s}
|
||||
.slider-prev{left:24px}.slider-next{right:24px}
|
||||
.slider-prev::before,.slider-next::before{content:'';width:12px;height:12px;border-left:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);margin-left:5px}
|
||||
.slider-next::before{transform:rotate(-135deg);margin-left:-5px}
|
||||
.slider-controls .slider-prev:hover,.slider-controls .slider-next:hover{background:var(--slider-dot-active,var(--accent));color:#fff}
|
||||
.slider-prev:disabled,.slider-next:disabled{opacity:.4;cursor:default}
|
||||
.slider-position{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
||||
.slider-control-dots{position:absolute;right:70px;bottom:20px;display:flex;gap:3px}
|
||||
.slider-control-dots button{display:flex;align-items:center;justify-content:center;width:24px;height:24px;font-size:0;transition:width .25s}
|
||||
.slider-control-dots button::before{content:'';width:9px;height:9px;border-radius:99px;background:var(--slider-dot,rgba(255,255,255,.55));transition:width .25s,background-color .25s}
|
||||
.slider-control-dots button[aria-pressed=true]{width:40px}
|
||||
.slider-control-dots button[aria-pressed=true]::before{width:28px;background:var(--slider-dot-active,var(--accent))}
|
||||
.slider-control-dots button:hover::before{background:var(--slider-dot-active,var(--accent))}
|
||||
.slider-controls .slider-play{position:absolute;right:24px;bottom:16px;width:32px;height:32px;border-radius:50%;background:rgba(0,0,0,.35);color:#fff;font-size:0;display:flex;align-items:center;justify-content:center}
|
||||
.slider-play::before{content:'';width:0;height:0;border-left:9px solid currentColor;border-top:6px solid transparent;border-bottom:6px solid transparent;margin-left:2px}
|
||||
.slider-play[data-playing=true]::before{width:8px;height:11px;border:0;border-left:3px solid currentColor;border-right:3px solid currentColor;margin-left:0}
|
||||
.slider-play:disabled{opacity:.4;cursor:default}
|
||||
.slider-controls[hidden]{display:none}
|
||||
@media(max-width:767px){.slider-controls .slider-prev,.slider-controls .slider-next{display:none}.slider-control-dots{right:54px;bottom:14px}.slider-controls .slider-play{right:14px;bottom:12px}}
|
||||
|
||||
.slide-desktop-1{grid-auto-columns:100%}.slide-desktop-2{grid-auto-columns:calc(50% - 10px)}
|
||||
.slide-desktop-3{grid-auto-columns:calc(33.333% - 14px)}.slide-desktop-4{grid-auto-columns:calc(25% - 15px)}
|
||||
@@ -214,7 +330,7 @@ body.consent-open{padding-bottom:260px}
|
||||
.slide-tablet-3{grid-auto-columns:calc(33.333% - 14px)}.slide-tablet-4{grid-auto-columns:calc(25% - 15px)}
|
||||
.slide-tablet-5{grid-auto-columns:calc(20% - 16px)}.slide-tablet-6{grid-auto-columns:calc(16.666% - 17px)}
|
||||
}
|
||||
@media (max-width:650px){
|
||||
@media (max-width:767px){
|
||||
.slide-mobile-1{grid-auto-columns:100%}.slide-mobile-2{grid-auto-columns:calc(50% - 10px)}
|
||||
.slide-mobile-3{grid-auto-columns:calc(33.333% - 14px)}.slide-mobile-4{grid-auto-columns:calc(25% - 15px)}
|
||||
.slide-mobile-5{grid-auto-columns:calc(20% - 16px)}.slide-mobile-6{grid-auto-columns:calc(16.666% - 17px)}
|
||||
@@ -324,13 +440,20 @@ body.consent-open{padding-bottom:260px}
|
||||
/* Section widths. "wide" only widens the content, "full" removes the container entirely: that is
|
||||
what a picture or a colour field from edge to edge needs. A full width section drops its own
|
||||
padding as well, otherwise "edge to edge" would still have 32 pixels of air on each side. */
|
||||
.width-wide>.section-inner{width:min(1600px,calc(100% - 64px))}
|
||||
.width-full>.section-inner{width:100%;max-width:none;margin-inline:0}
|
||||
@media (max-width:650px){.width-wide>.section-inner{width:calc(100% - 36px)}}
|
||||
/* Mit ".kant" davor, weil das Design ".kant .shell{max-width:var(--k-container)}" schreibt: ohne
|
||||
den Zusatz gewann diese Regel und ein breiter oder voller Abschnitt blieb so schmal wie alle. */
|
||||
.kant .width-wide>.section-inner{width:min(1600px,calc(100% - 64px));max-width:none}
|
||||
.kant .width-full>.section-inner{width:100%;max-width:none;margin-inline:0}
|
||||
@media (max-width:767px){.kant .width-wide>.section-inner{width:calc(100% - 36px)}}
|
||||
|
||||
/* A section that is a flex container centres its content box; the inner container must then be
|
||||
allowed to grow, or a full height section would show a strip in the middle. */
|
||||
.block-section>.section-inner{flex:1 1 auto}
|
||||
/* Ein Abschnitt, der ein Flex-Kasten ist, reiht seinen Inhaltskasten als Flex-Element ein.
|
||||
Wachsen darf der dabei nicht: die Laufrichtung ist voreingestellt die Zeile, und dort frisst
|
||||
das Wachsen die Randluft des Containers. Auf dem Telefon stand der Text eines Hero-Abschnitts
|
||||
damit hart an der Kante, waehrend Kopf und alle uebrigen Abschnitte ihre 18 px hatten - am
|
||||
Schreibtisch fiel es nicht auf, weil dort die Hoechstbreite des Designs vorher bremst.
|
||||
Die Hoehe braucht das Wachsen nicht: quer zur Laufrichtung dehnt sich ein Flex-Element
|
||||
ohnehin ueber die volle Hoehe, und steht "Ausrichtung: Mitte", ist die Mitte gewollt. */
|
||||
.block-section>.section-inner{flex:0 1 auto}
|
||||
|
||||
/* Menu kinds. Horizontal is the default; vertical stacks; burger is a details element, so it
|
||||
opens without a script - a menu that needs one would not open on a page that runs none. */
|
||||
@@ -353,12 +476,64 @@ body.consent-open{padding-bottom:260px}
|
||||
}
|
||||
.menu-burger>summary{min-height:44px;min-width:44px}
|
||||
.menu-burger-body{overflow-wrap:anywhere}
|
||||
/* Im Kopfbereich klappt das Menue als Flaeche unter dem Kopf auf, ueber die volle Breite.
|
||||
Inline geoeffnet stand es sonst in der schmalen Spalte neben der Marke und schob den
|
||||
ganzen Kopf auseinander - auf dem Telefon sah es aus, als fehle das Menue. */
|
||||
.site-header{position:relative}
|
||||
/* Gibt der Kopfbereich seine Flaeche selbst vor, malt der klebende Kopf keine zweite dahinter:
|
||||
sonst zeigte eine durchsichtige Sektion Weiss statt Seite, und die Schattenlinie liesse sich
|
||||
weder faerben noch abschalten. Linie und Schatten kommen dann aus Rahmen und Wirkung. */
|
||||
.site-header.is-sticky.has-own-surface{background:transparent;box-shadow:none}
|
||||
.site-header .menu-burger[open]>summary{border-radius:8px}
|
||||
/* Die Flaeche malt ihren eigenen Untergrund, also setzt sie auch ihre eigene Schriftfarbe.
|
||||
Ohne das erbte sie die Farbe des Kopfbereichs: bei einem Kopf, der ueber einem dunklen Bild
|
||||
schwebt und deshalb weiss schreibt, stand das aufgeklappte Menue weiss auf weiss. */
|
||||
.site-header .menu-burger-body{position:absolute;left:0;right:0;top:100%;z-index:30;
|
||||
padding:15px max(16px,calc((100% - var(--k-container,1180px)) / 2 + 16px)) 25px;
|
||||
background:var(--paper);color:var(--ink);
|
||||
box-shadow:0 15px 25px color-mix(in srgb,var(--ink) 12%,transparent)}
|
||||
.site-header .menu-burger-body .menu-list{gap:0}
|
||||
.site-header .menu-burger-body .menu-list a{padding:12px 10px;font-size:16px}
|
||||
/* Menüeinträge als Button (ADR 110). Die Menüregeln für Links (Polster, Unterstreichung, Schrift
|
||||
im aufgeklappten Kopf) gelten nicht für sie - sie sehen aus wie jeder andere Button. */
|
||||
.menu-list a.cms-button,.menu-actions a.cms-button{display:inline-flex;padding:var(--b-py,var(--kb-py,15px)) var(--b-px,var(--kb-px,21px));font-size:var(--kb-font-size,14px);text-decoration:none;margin:0}
|
||||
.menu-list a.cms-button:hover,.menu-list a.cms-button[aria-current=page]{text-decoration:none}
|
||||
.menu-list a.cms-button.b-desktop-appearance-link{text-decoration:underline}
|
||||
.site-header .menu-burger-body .menu-list a.cms-button{padding:var(--b-py,var(--kb-py,15px)) var(--b-px,var(--kb-px,21px));font-size:var(--kb-font-size,14px)}
|
||||
/* Im aufgeklappten Menü volle Breite und etwas Luft zu den Links darüber. */
|
||||
.menu-burger-body .menu-item-button{align-self:stretch;margin-top:8px}
|
||||
.menu-burger-body .menu-item-button>.cms-button:not(.icon-only){width:100%}
|
||||
/* Neben dem Menü-Knopf stehen bleiben: nur solange das Menü zusammengeklappt ist. */
|
||||
.menu-actions{display:none;list-style:none;margin:0;padding:0;gap:8px;align-items:center;flex:0 1 auto;min-width:0}
|
||||
/* Neben dem Menü-Knopf ist wenig Platz: kompakter, in einer Zeile mit dem Knopf. */
|
||||
.menu-actions a.cms-button{--b-py:8px;--b-px:14px;white-space:nowrap}
|
||||
.menu-actions a.cms-button.icon-only{--b-px:8px}
|
||||
nav.menu-burger>.menu-actions{display:flex}
|
||||
nav.menu-burger:has(>.menu-actions){display:flex;flex-wrap:nowrap;align-items:center;justify-content:flex-end;gap:8px}
|
||||
nav.menu-burger .menu-burger-body .menu-keep-visible{display:none}
|
||||
@media (max-width:1024px),(hover:none) and (pointer:coarse){
|
||||
.menu-collapse-tablet>.menu-actions{display:flex}
|
||||
.menu-collapse-tablet:has(>.menu-actions){display:flex;flex-wrap:nowrap;align-items:center;justify-content:flex-end;gap:8px}
|
||||
.menu-collapse-tablet .menu-burger-body .menu-keep-visible{display:none}
|
||||
}
|
||||
@media (max-width:767px){
|
||||
.menu-collapse-mobile>.menu-actions{display:flex}
|
||||
.menu-collapse-mobile:has(>.menu-actions){display:flex;flex-wrap:nowrap;align-items:center;justify-content:flex-end;gap:8px}
|
||||
.menu-collapse-mobile .menu-burger-body .menu-keep-visible{display:none}
|
||||
}
|
||||
/* Visibility never overwrites the block's grid/flex/display settings. */
|
||||
.visibility-hidden{display:none!important}
|
||||
.slider-controls button[hidden]{display:none}
|
||||
@media (min-width:1025px){.visibility-tablet,.visibility-mobile,.visibility-hide-desktop{display:none!important}}
|
||||
@media (min-width:768px) and (max-width:1024px){.visibility-desktop,.visibility-mobile,.visibility-hide-tablet{display:none!important}}
|
||||
@media (max-width:767px){.visibility-desktop,.visibility-tablet,.visibility-hide-mobile{display:none!important}}
|
||||
/* Das Sprungziel des Knopfs „nach oben“, ein leerer <span> am Anfang von <body>.
|
||||
WebKit springt nicht zu einem Ziel, dessen Box inline ist und keine Flaeche hat - der Tipp
|
||||
kam an, #seitenanfang stand in der Adresse, und die Seite blieb stehen. Auf dem iPhone,
|
||||
und ebenso in Safari auf dem Mac; Chromium ist toleranter, deshalb fiel es am Schreibtisch
|
||||
nie auf. Ein leerer Block ohne Hoehe reicht WebKit, es ist die Inline-Box, die stoert.
|
||||
Nachgestellt in Playwright-WebKit mit iPhone-Profil: inline bleibt bei 1500, block springt. */
|
||||
#seitenanfang{display:block}
|
||||
/* Der Knopf „nach oben". Ein Anker, kein Skript: er springt an den Anfang der Seite, und das
|
||||
kann ein Browser seit jeher allein. Eingeblendet wird er beim Scrollen über eine Scroll-
|
||||
Zeitachse - dieselbe Technik wie bei den Einblendungen. Wo es die nicht gibt (heute noch
|
||||
@@ -369,20 +544,38 @@ body.consent-open{padding-bottom:260px}
|
||||
.scroll-top:hover{filter:brightness(1.08)}
|
||||
.scroll-top:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
|
||||
@media (max-width:767px){.scroll-top{bottom:16px;right:16px}.scroll-top-links{left:16px;right:auto}}
|
||||
/* Solange der Einwilligungsdialog unten steht, würde der Knopf darauf liegen. */
|
||||
body.consent-open .scroll-top{display:none}
|
||||
@supports (animation-timeline: scroll()){
|
||||
.scroll-top{animation:kant-scroll-top linear both;animation-timeline:scroll(root);animation-range:var(--kant-top-after,400px) calc(var(--kant-top-after,400px) + 160px)}
|
||||
@keyframes kant-scroll-top{from{opacity:0;visibility:hidden;transform:translateY(10px)}to{opacity:1;visibility:visible;transform:none}}
|
||||
/* Beachtet die Website die Browservorgabe (Einstellungen → Bewegung), steht er von Anfang an da. */
|
||||
@media (prefers-reduced-motion: reduce){html.motion-respect .scroll-top{animation:none}}
|
||||
/* Solange die Einwilligungsfrage offen ist, bleibt der Knopf da. Frueher verschwand er ganz -
|
||||
und auf dem Telefon bleibt die Frage oft einfach stehen, der Knopf kam dort also nie.
|
||||
Bis 799 px deckt die Leiste die Ecke ab, in der er steht: dort setzt er sich per
|
||||
Ankerpositionierung auf ihre Oberkante, wie hoch ihr Text auch umbricht. Ab 800 px steht die
|
||||
Leiste zentriert mit Rand, die Ecke bleibt frei. Ein Browser ohne Ankerpositionierung blendet
|
||||
ihn in der schmalen Stufe weiter aus - lieber kein Knopf als einer unter der Leiste. Der Anker
|
||||
muss im Dokument vor dem Knopf stehen; page.html.twig gibt die Leiste deshalb zuerst aus. */
|
||||
.consent-bar{anchor-name:--kant-consent}
|
||||
@media (max-width:799px){
|
||||
@supports (anchor-name:--kant-consent){body.consent-open .scroll-top{position-anchor:--kant-consent;bottom:calc(anchor(top) + 12px)}}
|
||||
@supports not (anchor-name:--kant-consent){body.consent-open .scroll-top{display:none}}
|
||||
}
|
||||
/* Leiste und Ecke decken die Ecke auf jeder Breite ab - dort gilt dasselbe ohne Stufe. */
|
||||
@supports (anchor-name:--kant-consent){body.consent-open:has(.consent-layout-leiste,.consent-layout-ecke) .scroll-top{position-anchor:--kant-consent;bottom:calc(anchor(top) + 12px)}}
|
||||
@supports not (anchor-name:--kant-consent){body.consent-open:has(.consent-layout-leiste,.consent-layout-ecke) .scroll-top{display:none}}
|
||||
/* Sichtbar, sobald der Leser ein Stueck gescrollt hat - gesteuert von scrolled.js ueber
|
||||
`kant-top-armed` und `kant-top-shown`. Ohne Skript bleibt der Knopf einfach sichtbar. */
|
||||
html.kant-top-armed .scroll-top{opacity:0;visibility:hidden;transform:translateY(10px);transition:opacity .25s,visibility .25s,transform .25s}
|
||||
html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;transform:none}
|
||||
@media (prefers-reduced-motion: reduce){html.motion-respect .scroll-top{transition:none}}
|
||||
/* Der Bildbetrachter. Die Fläche daneben schließt, das Bild bleibt in seinen Grenzen, und die
|
||||
Bedienknöpfe sind groß genug für einen Daumen. Ohne Skript wird nichts davon gebraucht - dann
|
||||
öffnet der Verweis einfach das große Bild. */
|
||||
.block-legal{display:flex;flex-wrap:wrap;gap:6px;align-items:center;font-size:13px}
|
||||
.block-legal a{color:inherit;text-decoration:none}
|
||||
.block-legal a:hover{text-decoration:underline}
|
||||
/* Die Sprachwahl (ADR 111): eine kurze Reihe, die gewählte Sprache fett. Jedes Ziel mindestens
|
||||
24 px, damit es mit dem Finger zu treffen ist. */
|
||||
.block-language ul{display:flex;flex-wrap:wrap;gap:2px;align-items:center;list-style:none;margin:0;padding:0}
|
||||
.block-language a{display:inline-flex;align-items:center;justify-content:center;min-width:24px;min-height:24px;padding:4px 8px;color:inherit;text-decoration:none;font-size:14px;line-height:1.2}
|
||||
.block-language a:hover{text-decoration:underline}
|
||||
.block-language a[aria-current]{font-weight:700}
|
||||
.image-zoom{cursor:zoom-in}
|
||||
.zoom-dialog{border:0;padding:0;max-width:min(96vw,1400px);max-height:94vh;background:transparent;color:#fff;overflow:visible}
|
||||
.zoom-dialog::backdrop{background:rgba(8,12,20,.88)}
|
||||
@@ -400,5 +593,36 @@ body.consent-open .scroll-top{display:none}
|
||||
.zoom-close,.zoom-prev,.zoom-next{width:46px;height:46px}
|
||||
.zoom-prev{left:8px}.zoom-next{right:8px}
|
||||
}
|
||||
/* Der PDF-Betrachter: fast bildschirmfüllend, mit einer schmalen Leiste darüber. Den Rest macht
|
||||
der Betrachter des Browsers - Blättern, Zoom, Drucken. */
|
||||
.pdf-dialog{border:0;padding:0;width:min(96vw,1200px);height:94vh;max-width:none;max-height:none;background:#1b212c;color:#fff;border-radius:12px;overflow:hidden}
|
||||
.pdf-dialog[open]{display:flex;flex-direction:column}
|
||||
.pdf-dialog::backdrop{background:rgba(8,12,20,.88)}
|
||||
.pdf-bar{display:flex;align-items:center;gap:10px;padding:10px 10px 10px 18px}
|
||||
.pdf-title{flex:1;min-width:0;margin:0;font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#fff}
|
||||
.pdf-action{color:#fff;font-size:13px;text-decoration:none;padding:9px 14px;border-radius:999px;background:rgba(255,255,255,.12);white-space:nowrap}
|
||||
.pdf-action:hover{background:rgba(255,255,255,.24)}
|
||||
.pdf-close{display:grid;place-items:center;width:40px;height:40px;border-radius:999px;border:0;background:rgba(255,255,255,.14);color:#fff;font-size:24px;line-height:1;cursor:pointer}
|
||||
.pdf-close:hover{background:rgba(255,255,255,.26)}
|
||||
.pdf-action:focus-visible,.pdf-close:focus-visible{outline:3px solid #fff;outline-offset:2px}
|
||||
.pdf-frame{flex:1;width:100%;border:0;background:#525659}
|
||||
|
||||
.has-anchor{position:relative}.block-anchor{position:absolute;top:0;left:0;width:1px;height:1px;pointer-events:none;scroll-margin-top:110px}.nav-label{display:inline-flex;align-items:center;gap:.5em}.nav-label>.block-icon{width:1.1em;height:1.1em;color:inherit;flex:none}
|
||||
|
||||
/* Soziale Profile: eine Symbolreihe, Farbe und Abstand vom Block, Größe als Klasse. */
|
||||
.block-social{display:flex;flex-wrap:wrap;gap:12px;align-items:center;margin:0;padding:0;list-style:none}.block-social.social-vertical{flex-direction:column;align-items:flex-start}.block-social a{display:inline-flex;align-items:center;gap:10px;color:inherit;text-decoration:none;line-height:1;transition:transform .2s,background-color .2s,opacity .2s}.block-social a:hover{transform:translateY(-2px)}.block-social svg{display:block;flex:none}.block-social.social-labels a{font-size:.875em;font-weight:600}.social-shape-circle a,.social-shape-square a{padding:.5em;background:color-mix(in srgb,currentColor 10%,transparent)}.social-shape-circle a{border-radius:999px}.social-shape-square a{border-radius:.35em}.social-shape-circle a:hover,.social-shape-square a:hover{background:color-mix(in srgb,currentColor 18%,transparent)}.social-labels.social-shape-circle a,.social-labels.social-shape-square a{padding-inline:.8em}.social-size-12 svg{width:12px;height:12px}.social-size-16 svg{width:16px;height:16px}.social-size-20 svg{width:20px;height:20px}.social-size-24 svg{width:24px;height:24px}.social-size-32 svg{width:32px;height:32px}.social-size-40 svg{width:40px;height:40px}.social-size-48 svg{width:48px;height:48px}.social-size-56 svg{width:56px;height:56px}.social-size-64 svg{width:64px;height:64px}.social-size-80 svg{width:80px;height:80px}.social-size-96 svg{width:96px;height:96px}.social-size-120 svg{width:120px;height:120px}.social-size-160 svg{width:160px;height:160px}.social-size-200 svg{width:200px;height:200px}.social-size-240 svg{width:240px;height:240px}
|
||||
@media(prefers-reduced-motion:reduce){html.motion-respect .block-social a{transition:none}html.motion-respect .block-social a:hover{transform:none}}
|
||||
|
||||
/* Druck: eine Seite, die man mitnimmt. Kein Kopf mit Menü, keine Einwilligung, kein Nach-oben,
|
||||
keine Formularknöpfe; Verweise zeigen ihre Adresse, Bilder bleiben auf der Seite. */
|
||||
@media print{
|
||||
.kant{background:#fff;color:#000}
|
||||
.site-header nav,.menu-toggle,.skip-link,.scroll-top,.consent-bar,.consent-banner,.block-form .cms-button,.form-step-nav,.form-progress,.post-pager,.lightbox,.slider-controls,.slider-dots,.tabs-nav,.cf-turnstile{display:none!important}
|
||||
.site-header,.site-footer{border:0}
|
||||
.site-header.is-sticky,.site-header.is-overlay,.site-header.is-overlay.is-sticky{position:static;background:transparent;box-shadow:none}
|
||||
a[href^="http"]::after{content:" (" attr(href) ")";font-size:.85em;color:#444}
|
||||
.block-section{padding:16px 0;break-inside:avoid}
|
||||
img{max-width:100%;break-inside:avoid}
|
||||
.tone-ink,.tone-mint{background:transparent;color:#000}
|
||||
[data-step][hidden],.block-form [hidden]{display:block!important}
|
||||
}
|
||||
+12
-6
@@ -56,9 +56,9 @@ export function initSliders(doc = document) {
|
||||
go(direction > 0 ? (last ? visible[0] : visible[Math.min(visible.length - 1, position + 1)]) : (first ? visible.at(-1) : visible[Math.max(0, position - 1)]));
|
||||
};
|
||||
const manual = action => { playing = false; clearTimeout(timer); update(); action(); };
|
||||
const previous = button('←', 'Vorherige Folie', () => manual(() => step(-1)));
|
||||
const previous = button('←', 'Vorherige Folie', () => manual(() => step(-1))); previous.className = 'slider-prev';
|
||||
const count = doc.createElement('span'); count.className = 'slider-position'; controls.append(count);
|
||||
const next = button('→', 'Nächste Folie', () => manual(() => step(1)));
|
||||
const next = button('→', 'Nächste Folie', () => manual(() => step(1))); next.className = 'slider-next';
|
||||
const dots = [];
|
||||
if (slider.classList.contains('slider-dots')) {
|
||||
const group = doc.createElement('div'); group.className = 'slider-control-dots'; controls.append(group);
|
||||
@@ -67,6 +67,7 @@ export function initSliders(doc = document) {
|
||||
});
|
||||
}
|
||||
const play = seconds >= 3 ? button('', 'Automatische Wiedergabe pausieren', () => { playing = !playing; update(); schedule(); }) : null;
|
||||
if (play) play.className = 'slider-play';
|
||||
const update = () => {
|
||||
const visible = available();
|
||||
const fits = visible.length < 2 || slider.scrollWidth <= slider.clientWidth + 2;
|
||||
@@ -74,8 +75,9 @@ export function initSliders(doc = document) {
|
||||
const index = active(); count.textContent = `${Math.max(0, visible.indexOf(index) + 1)} / ${visible.length}`;
|
||||
dots.forEach((dot, i) => { dot.hidden = !visible.includes(i); dot.setAttribute('aria-pressed', String(i === index)); });
|
||||
if (play) {
|
||||
play.textContent = playing ? 'Pause' : 'Wiedergabe';
|
||||
play.setAttribute('aria-label', playing ? 'Automatische Wiedergabe pausieren' : 'Automatische Wiedergabe starten');
|
||||
play.textContent = playing ? 'Pause' : 'Wiedergabe'; play.dataset.playing = String(playing);
|
||||
// Der zugängliche Name beginnt mit dem sichtbaren Text (WCAG 2.5.3): wer „Pause“ liest, kann „Pause“ sagen.
|
||||
play.setAttribute('aria-label', playing ? 'Pause: automatische Wiedergabe anhalten' : 'Wiedergabe: automatisch weiterblättern');
|
||||
play.disabled = reduced(); play.title = reduced() ? 'Automatik ist bei reduzierter Bewegung deaktiviert.' : '';
|
||||
}
|
||||
};
|
||||
@@ -84,7 +86,11 @@ export function initSliders(doc = document) {
|
||||
if (playing && !reduced()) timer = setTimeout(() => { if (playing && !hover && visible && !doc.hidden) step(1); schedule(); }, seconds * 1000);
|
||||
};
|
||||
const pause = () => { playing = false; update(); clearTimeout(timer); };
|
||||
slider.after(controls); slider.classList.add('slider-controlled'); update();
|
||||
// Slider and controls share a positioned shell, so arrows and dots can lie on the picture
|
||||
// the way the Kant-IT hero slider draws them.
|
||||
const shell = doc.createElement('div'); shell.className = 'slider-shell';
|
||||
slider.replaceWith(shell); shell.append(slider, controls);
|
||||
slider.classList.add('slider-controlled'); update();
|
||||
slider.addEventListener('scroll', update, { passive: true, signal });
|
||||
view.addEventListener('resize', update, { passive: true, signal });
|
||||
slider.addEventListener('pointerenter', () => { hover = true; }, { signal });
|
||||
@@ -98,7 +104,7 @@ export function initSliders(doc = document) {
|
||||
const resize = new view.ResizeObserver(update); resize.observe(slider);
|
||||
preference.addEventListener('change', () => { if (reduced()) pause(); update(); }, { signal });
|
||||
schedule();
|
||||
cleanups.push(() => { clearTimeout(timer); clearInterval(settle); slider.style.scrollSnapType = ''; events.abort(); observer.disconnect(); resize.disconnect(); controls.remove(); slider.classList.remove('slider-controlled'); delete slider.dataset.enhanced; });
|
||||
cleanups.push(() => { clearTimeout(timer); clearInterval(settle); slider.style.scrollSnapType = ''; events.abort(); observer.disconnect(); resize.disconnect(); controls.remove(); shell.replaceWith(slider); slider.classList.remove('slider-controlled'); delete slider.dataset.enhanced; });
|
||||
});
|
||||
return () => cleanups.forEach(cleanup => cleanup());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft.
|
||||
*
|
||||
* Beim Öffnen der Systemseite fragt sie selbst, ob es eine neue Fassung gibt, und zeigt deren
|
||||
* Hinweise und einen Knopf. Nach der Bestätigung startet eine Anfrage das Update (Sichern, Laden,
|
||||
* Umschalten); diese Seite fragt währenddessen alle anderthalb Sekunden den Fortschritt ab. Ist
|
||||
* der Tausch durch, ruft sie den Abschluss auf - eine neue Anfrage mit dem neuen Code:
|
||||
* Datenbank anpassen, prüfen, Wartungsseite aus. Schlägt etwas fehl, schaltet der Server zurück
|
||||
* und die Seite sagt es.
|
||||
*
|
||||
* Ohne Skript bleiben die Formulare unter „Weitere Möglichkeiten“.
|
||||
*/
|
||||
const karte = document.querySelector('[data-update]');
|
||||
if (karte) {
|
||||
const live = karte.querySelector('[data-update-live]');
|
||||
const dialog = document.getElementById('update-dialog');
|
||||
const csrf = karte.dataset.csrf;
|
||||
const SCHRITTE = [
|
||||
['sichern', 'Daten sichern'],
|
||||
['laden', 'Fassung laden und prüfen'],
|
||||
['umschalten', 'Umschalten'],
|
||||
['migrieren', 'Datenbank anpassen'],
|
||||
['pruefen', 'Funktion prüfen'],
|
||||
];
|
||||
let ziel = null, timer = null, laeuft = false;
|
||||
|
||||
const el = (tag, className, text) => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
const datum = iso => iso ? new Date(iso + 'T00:00:00').toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' }) : '';
|
||||
const abfragen = async url => {
|
||||
const antwort = await fetch(url, { credentials: 'same-origin', cache: 'no-store', headers: { Accept: 'application/json' } });
|
||||
if (!antwort.ok) throw new Error(String(antwort.status));
|
||||
return antwort.json();
|
||||
};
|
||||
const senden = async (url, daten) => {
|
||||
const antwort = await fetch(url, {
|
||||
method: 'POST', credentials: 'same-origin', cache: 'no-store',
|
||||
headers: { 'X-CSRF-Token': csrf, Accept: 'application/json' },
|
||||
body: new URLSearchParams(daten),
|
||||
});
|
||||
const text = await antwort.text();
|
||||
try { return JSON.parse(text); } catch { throw new Error(`Antwort ${antwort.status}`); }
|
||||
};
|
||||
|
||||
// Die Hinweise der Releaseseite sind Markdown. Gezeigt wird Text mit Zeilen und **fett** -
|
||||
// als Textknoten, nie als HTML: was dort steht, kommt von außen.
|
||||
function notizen(text) {
|
||||
const box = el('div', 'update-notizen');
|
||||
text.split('\n').forEach((zeile, index) => {
|
||||
if (index) box.append(document.createElement('br'));
|
||||
zeile.replace(/^#+\s*/, '').split(/(\*\*[^*]+\*\*)/).forEach(teil => {
|
||||
box.append(teil.startsWith('**') && teil.endsWith('**') && teil.length > 4 ? el('strong', '', teil.slice(2, -2)) : document.createTextNode(teil));
|
||||
});
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Ansicht: Prüfen
|
||||
function warten(text) {
|
||||
live.hidden = false;
|
||||
live.replaceChildren(el('p', 'update-wait', text));
|
||||
live.firstChild.prepend(el('span', 'update-spinner'));
|
||||
}
|
||||
|
||||
function zeigeErgebnis(daten) {
|
||||
live.hidden = false;
|
||||
live.replaceChildren();
|
||||
const letzter = daten.status;
|
||||
if (letzter && !letzter.laeuft && ['fertig', 'fehler', 'zurueckgerollt'].includes(letzter.phase) && letzter.auf) {
|
||||
const box = el('p', `notice ${letzter.phase === 'fertig' ? 'done' : 'error'} update-letztes`);
|
||||
box.append(el('strong', '', letzter.phase === 'fertig' ? `Letztes Update auf ${letzter.auf} erfolgreich.` : `Letztes Update auf ${letzter.auf} nicht durchgelaufen.`));
|
||||
if (letzter.phase !== 'fertig') box.append(' ', letzter.meldung || '');
|
||||
live.append(box);
|
||||
}
|
||||
if (daten.fehler) {
|
||||
const zeile = el('p', 'notice error', daten.fehler + ' ');
|
||||
const nochmal = el('button', 'link-button', 'Erneut fragen'); nochmal.type = 'button';
|
||||
nochmal.addEventListener('click', pruefen);
|
||||
zeile.append(nochmal); live.append(zeile);
|
||||
return;
|
||||
}
|
||||
if (!daten.neu) {
|
||||
live.append(el('p', 'update-aktuell', `Du bist auf dem neuesten Stand (${daten.installiert}).`));
|
||||
return;
|
||||
}
|
||||
ziel = daten.neu.version;
|
||||
const box = el('div', 'update-neu');
|
||||
box.append(el('p', 'update-neu-label', 'Neue Fassung verfügbar'));
|
||||
box.append(el('h3', '', daten.neu.name || `KantSites ${ziel}`));
|
||||
box.append(el('p', 'muted', [ziel, daten.neu.datum ? `veröffentlicht am ${datum(daten.neu.datum)}` : ''].filter(Boolean).join(' · ')));
|
||||
if (daten.neu.notizen) box.append(notizen(daten.neu.notizen));
|
||||
const knopf = el('button', 'primary', 'Jetzt aktualisieren'); knopf.type = 'button';
|
||||
knopf.addEventListener('click', () => { dialog.querySelector('[data-update-ziel]').textContent = ziel; dialog.showModal(); });
|
||||
box.append(knopf);
|
||||
live.append(box);
|
||||
}
|
||||
|
||||
async function pruefen() {
|
||||
warten('Frage nach neuen Fassungen …');
|
||||
try {
|
||||
const daten = await abfragen(karte.dataset.pruefen);
|
||||
if (daten.status?.laeuft) { zeigeLauf(daten.status); verfolgen(); return; }
|
||||
zeigeErgebnis(daten);
|
||||
} catch {
|
||||
zeigeErgebnis({ fehler: 'Die Abfrage ist fehlgeschlagen.' });
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Ansicht: Lauf
|
||||
function zeigeLauf(status, server = true) {
|
||||
live.hidden = false;
|
||||
const box = el('div', 'update-lauf');
|
||||
const kopf = el('p', 'update-lauf-kopf');
|
||||
const ende = ['fertig', 'fehler', 'zurueckgerollt'].includes(status.phase);
|
||||
if (!ende) kopf.append(el('span', 'update-spinner'));
|
||||
kopf.append(ende
|
||||
? (status.phase === 'fertig' ? `Update auf ${status.auf} abgeschlossen` : `Update auf ${status.auf} nicht durchgelaufen`)
|
||||
: `Update auf ${status.auf || ziel || ''} läuft`);
|
||||
box.append(kopf);
|
||||
// Bei einem Fehler oder beim Zurückschalten markiert die Liste den Schritt, in dem es passierte.
|
||||
const gescheitert = ['fehler', 'zurueckgerollt', 'zurueckschalten'].includes(status.phase);
|
||||
const aktuell = gescheitert
|
||||
? Math.max(0, SCHRITTE.findIndex(([phase]) => phase === status.bei))
|
||||
: SCHRITTE.findIndex(([phase]) => phase === status.phase);
|
||||
const liste = el('ol', 'update-schritte');
|
||||
SCHRITTE.forEach(([phase, text], index) => {
|
||||
let zustand = index < aktuell || status.phase === 'fertig' ? 'erledigt' : index === aktuell && !ende ? 'aktiv' : 'offen';
|
||||
if (gescheitert && index === aktuell) zustand = 'fehler';
|
||||
const punkt = el('li', `update-schritt is-${zustand}`);
|
||||
punkt.append(el('span', 'update-marke', zustand === 'erledigt' ? '✓' : zustand === 'fehler' ? '!' : ''), el('span', '', text));
|
||||
punkt.querySelector('.update-marke').setAttribute('aria-hidden', 'true');
|
||||
punkt.append(el('span', 'visually-hidden', { erledigt: ' – erledigt', aktiv: ' – läuft', offen: '', fehler: ' – fehlgeschlagen' }[zustand]));
|
||||
liste.append(punkt);
|
||||
});
|
||||
box.append(liste);
|
||||
const meldung = el('p', `update-meldung${status.phase === 'fertig' ? ' is-ok' : ende ? ' is-fehler' : ''}`,
|
||||
server ? (status.meldung || '') : 'Der Server tauscht gerade die Dateien – die Seite meldet sich gleich wieder.');
|
||||
box.append(meldung);
|
||||
if (ende) {
|
||||
const weiter = el('button', 'secondary', status.phase === 'fertig' ? 'Seite neu laden' : 'Zur Übersicht'); weiter.type = 'button';
|
||||
weiter.addEventListener('click', () => location.reload());
|
||||
box.append(weiter);
|
||||
}
|
||||
live.replaceChildren(box);
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
try {
|
||||
const daten = await abfragen(karte.dataset.status);
|
||||
if (daten.status) zeigeLauf(daten.status);
|
||||
if (daten.installiert) karte.querySelector('[data-update-installiert]').textContent = daten.installiert;
|
||||
if (daten.status && !daten.status.laeuft && !laeuft) stoppen();
|
||||
} catch {
|
||||
// Während des Tauschs kann eine Abfrage scheitern; die nächste klappt wieder.
|
||||
const letzter = live.querySelector('.update-meldung');
|
||||
if (letzter) letzter.textContent = 'Der Server tauscht gerade die Dateien – die Seite meldet sich gleich wieder.';
|
||||
}
|
||||
}
|
||||
function verfolgen() { if (!timer) timer = setInterval(tick, 1500); }
|
||||
function stoppen() { clearInterval(timer); timer = null; }
|
||||
|
||||
async function starten() {
|
||||
dialog.close();
|
||||
laeuft = true;
|
||||
zeigeLauf({ phase: 'sichern', auf: ziel, meldung: 'Das Update beginnt …', laeuft: true });
|
||||
verfolgen();
|
||||
try {
|
||||
const start = await senden(karte.dataset.starten, { version: ziel });
|
||||
if (!start.ok) throw Object.assign(new Error(start.meldung || 'Das Update ist nicht durchgelaufen.'), { status: start.status });
|
||||
const ende = await senden(karte.dataset.abschliessen, { token: start.token });
|
||||
laeuft = false;
|
||||
await tick();
|
||||
stoppen();
|
||||
if (ende.ok) setTimeout(() => location.reload(), 2500);
|
||||
} catch (fehler) {
|
||||
laeuft = false;
|
||||
await tick();
|
||||
stoppen();
|
||||
if (!live.querySelector('.update-meldung.is-fehler')) zeigeLauf(fehler.status || { phase: 'fehler', auf: ziel, meldung: fehler.message });
|
||||
}
|
||||
}
|
||||
|
||||
dialog.querySelector('[data-update-abbrechen]').addEventListener('click', () => dialog.close());
|
||||
dialog.querySelector('[data-update-los]').addEventListener('click', starten);
|
||||
pruefen();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Kategorien und Schlagwörter eines Beitrags: das Textfeld mit der Liste durch Komma bleibt, was
|
||||
* gespeichert wird. Darunter stehen die vorhandenen Namen zum Anklicken - ein Klick nimmt einen
|
||||
* Namen auf oder heraus. Neue Namen tippt man weiter ein.
|
||||
*
|
||||
* Über den Dokumentknoten, weil die Suche in der Inhaltsliste die Ergebnisse austauscht.
|
||||
*/
|
||||
const split = value => value.split(',').map(name => name.trim()).filter(Boolean);
|
||||
const same = (a, b) => a.localeCompare(b, 'de', { sensitivity: 'base' }) === 0;
|
||||
|
||||
const enhance = input => {
|
||||
if (input.dataset.termReady) return;
|
||||
let names = [];
|
||||
try { names = JSON.parse(input.dataset.termInput || '[]'); } catch { names = []; }
|
||||
input.dataset.termReady = '1';
|
||||
if (!names.length) return;
|
||||
const box = document.createElement('div'); box.className = 'term-chips'; box.setAttribute('role', 'group');
|
||||
const label = document.querySelector(`label[for="${input.id}"]`)?.textContent || 'Vorhandene Namen';
|
||||
box.setAttribute('aria-label', `${label}: vorhandene zum Übernehmen`);
|
||||
const sync = () => {
|
||||
const chosen = split(input.value);
|
||||
for (const chip of box.children) chip.setAttribute('aria-pressed', String(chosen.some(name => same(name, chip.dataset.name))));
|
||||
};
|
||||
for (const name of names) {
|
||||
const chip = document.createElement('button'); chip.type = 'button'; chip.className = 'term-chip'; chip.dataset.name = name; chip.textContent = name;
|
||||
chip.addEventListener('click', () => {
|
||||
const chosen = split(input.value);
|
||||
const next = chosen.some(entry => same(entry, name)) ? chosen.filter(entry => !same(entry, name)) : [...chosen, name];
|
||||
input.value = next.join(', ');
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
sync();
|
||||
});
|
||||
box.append(chip);
|
||||
}
|
||||
input.addEventListener('input', sync);
|
||||
const help = input.parentElement.querySelector('.field-help');
|
||||
(help ?? input).after(box);
|
||||
sync();
|
||||
};
|
||||
const all = () => document.querySelectorAll('[data-term-input]').forEach(enhance);
|
||||
all();
|
||||
new MutationObserver(all).observe(document.body, { childList: true, subtree: true });
|
||||
@@ -7,7 +7,7 @@ if (root) {
|
||||
const read = () => Object.fromEntries([...root.querySelectorAll('[name^="buttons["]')].map(input => [input.name.slice(8, -1), input.value]));
|
||||
const update = () => {
|
||||
// Unsaved palette edits are reflected immediately in all samples.
|
||||
schema.colors = [...form.querySelectorAll('.theme-table tbody tr')].flatMap(row => {
|
||||
schema.colors = [...form.querySelectorAll('[data-color-card]')].flatMap(row => {
|
||||
const key = row.querySelector('[name$="[key]"]'), value = row.querySelector('[name$="[value]"]');
|
||||
return key?.value && value ? [{ key: key.value, value: value.value }] : [];
|
||||
});
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Die Designseite: Palette, Schriften, Grundtypografie.
|
||||
*
|
||||
* Ohne Skript sind es Karten mit Eingaben; eine Farbe ohne Schlüssel und eine Schrift ohne
|
||||
* Schlüssel werden nicht gespeichert, so lassen sie sich auch dann entfernen. Das Skript macht
|
||||
* daraus die eigentliche Werkstatt: Karten hinzufügen und entfernen, fertige Paletten übernehmen,
|
||||
* die Grundfarben aus der Palette wählen statt tippen, jede Schrift in sich selbst zeigen und die
|
||||
* Grundtypografie als lesbare Probe.
|
||||
*/
|
||||
import { createColorControl } from './color-control.js';
|
||||
|
||||
const form = document.querySelector('[data-theme-editor]');
|
||||
if (form) {
|
||||
const palettes = JSON.parse(form.dataset.palettes), colorNames = JSON.parse(form.dataset.colorNames);
|
||||
const colorList = form.querySelector('[data-color-list]'), colorTemplate = form.querySelector('[data-color-template]');
|
||||
const fontList = form.querySelector('[data-font-list]'), fontTemplate = form.querySelector('[data-font-template]');
|
||||
const colorCards = () => [...colorList.querySelectorAll('[data-color-card]')];
|
||||
const fontCards = () => [...fontList.querySelectorAll('[data-font-card]')];
|
||||
let nextColor = colorCards().length, nextFont = fontCards().length;
|
||||
const value = (card, selector) => card.querySelector(selector).value.trim();
|
||||
|
||||
const palette = () => colorCards().flatMap(card => {
|
||||
const key = value(card, '[data-color-key]').toLowerCase();
|
||||
return key ? [{ key, name: value(card, '[data-color-name]') || key, value: card.querySelector('[data-color-value]').value }] : [];
|
||||
});
|
||||
const fonts = () => fontCards().flatMap(card => {
|
||||
const key = value(card, '[data-font-key]').toLowerCase();
|
||||
return key ? [{ key, name: value(card, '[data-font-name]') || key, family: value(card, '[data-font-family]'), fallback: card.querySelector('[data-font-fallback]').value }] : [];
|
||||
});
|
||||
const stack = font => font.family ? `"${font.family}", ${font.fallback}` : font.fallback;
|
||||
const hex = raw => /^#[0-9a-f]{6}/i.test(raw || '') ? raw.slice(0, 7) : palette().find(color => `@${color.key}` === raw)?.value || '';
|
||||
|
||||
/* ---- Palette ---------------------------------------------------------------------- */
|
||||
|
||||
const refreshColors = () => {
|
||||
colorCards().forEach((card, index) => {
|
||||
const name = value(card, '[data-color-name]') || value(card, '[data-color-key]') || `Farbe ${index + 1}`;
|
||||
card.querySelector('[data-color-hex]').textContent = card.querySelector('[data-color-value]').value;
|
||||
card.querySelector('[data-color-value]').setAttribute('aria-label', `Farbwert von ${name}`);
|
||||
const remove = card.querySelector('[data-remove-color]');
|
||||
remove.hidden = false; remove.setAttribute('aria-label', `${name} entfernen`);
|
||||
});
|
||||
form.querySelector('[data-color-count]').textContent = String(palette().length);
|
||||
};
|
||||
const addColor = (values = {}) => {
|
||||
colorList.insertAdjacentHTML('beforeend', colorTemplate.innerHTML.replaceAll('__n__', String(nextColor++)));
|
||||
const card = colorList.lastElementChild; card.classList.remove('swatch-card-new');
|
||||
for (const [selector, field] of [['[data-color-key]', 'key'], ['[data-color-name]', 'name'], ['[data-color-value]', 'value']]) {
|
||||
if (values[field] !== undefined) card.querySelector(selector).value = values[field];
|
||||
}
|
||||
return card;
|
||||
};
|
||||
// Die leeren Karten braucht es nur ohne Skript: hier kommt jede Farbe über den Knopf.
|
||||
const emptyColors = colorCards().filter(card => card.classList.contains('swatch-card-new'));
|
||||
if (colorCards().length > emptyColors.length) emptyColors.forEach(card => card.remove());
|
||||
const addColorButton = form.querySelector('[data-add-color]'); addColorButton.hidden = false;
|
||||
addColorButton.addEventListener('click', () => {
|
||||
const card = addColor(); refreshColors(); mountBaseColors(); card.querySelector('[data-color-name]').focus();
|
||||
});
|
||||
colorList.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-remove-color]');
|
||||
if (!button) return;
|
||||
button.closest('[data-color-card]').remove(); refreshColors(); mountBaseColors(); renderType(); addColorButton.focus();
|
||||
});
|
||||
colorList.addEventListener('input', () => { refreshColors(); renderType(); });
|
||||
colorList.addEventListener('change', () => { mountBaseColors(); renderType(); });
|
||||
|
||||
const presets = form.querySelector('[data-palette-presets]'), status = form.querySelector('[data-palette-status]');
|
||||
presets.hidden = false;
|
||||
presets.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-palette-preset]');
|
||||
if (!button) return;
|
||||
const preset = palettes[Number(button.dataset.palettePreset)];
|
||||
// Bekannte Schlüssel bekommen den neuen Wert, fehlende kommen dazu, eigene Farben bleiben.
|
||||
for (const [key, color] of Object.entries(preset.colors)) {
|
||||
const card = colorCards().find(candidate => value(candidate, '[data-color-key]').toLowerCase() === key);
|
||||
if (card) card.querySelector('[data-color-value]').value = color;
|
||||
else addColor({ key, name: colorNames[key] || key, value: color });
|
||||
}
|
||||
refreshColors(); mountBaseColors(); renderType();
|
||||
status.textContent = `Palette „${preset.name}“ übernommen – sie gilt, sobald das Design gespeichert ist.`;
|
||||
});
|
||||
|
||||
/* ---- Schriften -------------------------------------------------------------------- */
|
||||
|
||||
const applySample = card => {
|
||||
const family = value(card, '[data-font-family]'), fallback = card.querySelector('[data-font-fallback]').value;
|
||||
card.querySelector('[data-font-sample]').style.fontFamily = stack({ family, fallback });
|
||||
const chosen = new Set([...card.querySelectorAll('[data-font-weight]')].filter(input => input.checked).map(input => input.value));
|
||||
for (const weight of card.querySelectorAll('[data-font-weights] > span')) weight.hidden = !chosen.has(weight.className.replace('font-weight-', ''));
|
||||
};
|
||||
const syncFontSelects = () => {
|
||||
const list = fonts();
|
||||
for (const select of form.querySelectorAll('[data-font-select]')) {
|
||||
const current = select.value, keep = select.querySelector('option[value="inherit"]');
|
||||
select.replaceChildren(...(keep ? [keep] : []), ...list.map(font => new Option(font.name, font.key)));
|
||||
select.value = list.some(font => font.key === current) || (keep && current === 'inherit') ? current : (keep ? 'inherit' : list[0]?.key ?? '');
|
||||
}
|
||||
};
|
||||
const refreshFonts = () => {
|
||||
fontCards().forEach((card, index) => {
|
||||
applySample(card);
|
||||
const name = value(card, '[data-font-name]') || value(card, '[data-font-key]') || `Schrift ${index + 1}`;
|
||||
const remove = card.querySelector('[data-remove-font]');
|
||||
remove.hidden = false; remove.setAttribute('aria-label', `${name} entfernen`);
|
||||
});
|
||||
form.querySelector('[data-font-count]').textContent = String(fonts().length);
|
||||
syncFontSelects();
|
||||
};
|
||||
const emptyFonts = fontCards().filter(card => card.classList.contains('font-card-new'));
|
||||
if (fontCards().length > emptyFonts.length) emptyFonts.forEach(card => card.remove());
|
||||
const addFontButton = form.querySelector('[data-add-font]'); addFontButton.hidden = false;
|
||||
addFontButton.addEventListener('click', () => {
|
||||
fontList.insertAdjacentHTML('beforeend', fontTemplate.innerHTML.replaceAll('__n__', String(nextFont++)));
|
||||
const card = fontList.lastElementChild; card.classList.remove('font-card-new');
|
||||
refreshFonts(); card.querySelector('[data-font-name]').focus();
|
||||
});
|
||||
fontList.addEventListener('click', event => {
|
||||
const button = event.target.closest('[data-remove-font]');
|
||||
if (!button) return;
|
||||
button.closest('[data-font-card]').remove(); refreshFonts(); renderType(); addFontButton.focus();
|
||||
});
|
||||
fontList.addEventListener('input', () => { refreshFonts(); renderType(); });
|
||||
fontList.addEventListener('change', () => { refreshFonts(); renderType(); });
|
||||
|
||||
/* ---- Grundtypografie -------------------------------------------------------------- */
|
||||
|
||||
const preview = form.querySelector('[data-type-preview]');
|
||||
const baseInputs = {};
|
||||
const lengthPattern = /^(0|-?\d{1,5}(\.\d{1,3})?(px|rem|em|%|vw|vh|ch))$/;
|
||||
function renderType() {
|
||||
const list = fonts();
|
||||
const family = key => { const font = list.find(entry => entry.key === key); return font ? stack(font) : 'inherit'; };
|
||||
const raw = form.querySelector('#base-size').value.trim(), size = lengthPattern.test(raw) ? raw : '18px';
|
||||
const line = form.querySelector('#base-line').value || '1.6', scale = parseFloat(form.querySelector('#base-scale').value) || 1.25;
|
||||
preview.style.fontFamily = family(form.querySelector('#base-font').value);
|
||||
preview.style.fontSize = size; preview.style.lineHeight = line;
|
||||
const heading = family(form.querySelector('#base-heading-font').value);
|
||||
for (const [selector, step] of [['[data-type-h1]', 4], ['[data-type-h2]', 3]]) {
|
||||
const node = preview.querySelector(selector);
|
||||
node.style.fontFamily = heading; node.style.fontSize = `calc(${size} * ${(scale ** step).toFixed(4)})`;
|
||||
}
|
||||
preview.style.color = hex(baseInputs.color?.value) || '#203b34';
|
||||
preview.style.background = hex(baseInputs.backgroundColor?.value) || '#ffffff';
|
||||
const link = preview.querySelector('[data-type-link]');
|
||||
link.style.color = hex(baseInputs.linkColor?.value) || 'inherit';
|
||||
link.style.textDecoration = form.querySelector('[data-base-underline]').checked ? 'underline' : 'none';
|
||||
}
|
||||
// Die drei Grundfarben als Palettenwahl. Die Palette kann sich unter ihnen ändern, deshalb
|
||||
// werden die Bedienelemente bei jeder Änderung der Palette neu aufgebaut; der Wert bleibt.
|
||||
function mountBaseColors() {
|
||||
const colors = palette();
|
||||
for (const input of form.querySelectorAll('[data-base-color]')) {
|
||||
const key = input.name.slice(5, -1);
|
||||
if (!input.dataset.controlId) {
|
||||
input.dataset.controlId = input.id; input.type = 'hidden'; input.id += '-value'; baseInputs[key] = input;
|
||||
}
|
||||
const id = input.dataset.controlId, label = form.querySelector(`label[for="${id}"]`).textContent;
|
||||
input.nextElementSibling?.classList.contains('color-control') && input.nextElementSibling.remove();
|
||||
const control = createColorControl({
|
||||
id, label, value: input.value, colors,
|
||||
onChange: chosen => { input.value = chosen; renderType(); }, onComplete: () => {},
|
||||
});
|
||||
control.querySelector('.color-inherit').hidden = true;
|
||||
input.after(control);
|
||||
}
|
||||
}
|
||||
|
||||
refreshColors(); refreshFonts(); mountBaseColors(); renderType();
|
||||
form.addEventListener('input', renderType); form.addEventListener('change', renderType);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Übersetzen im Editor (ADR 111).
|
||||
*
|
||||
* Eine Seite wird einmal gebaut; je weitere Sprache trägt ihr Dokument unter `translations` nur
|
||||
* die Texte, adressiert über die ID des Blocks. Dieses Modul kennt die übersetzbaren Felder,
|
||||
* schreibt eine Übersetzung in das Dokument und sagt, was fehlt oder veraltet ist. Es ändert
|
||||
* nie Aufbau, Gestaltung oder die Texte der Hauptsprache.
|
||||
*/
|
||||
import { locate, upgradeDocument, blockLabel } from './editor-model.js';
|
||||
|
||||
/** Dieselbe Liste wie Translations::FIELDS, mit Beschriftung, Länge und Art des Feldes. */
|
||||
export const FIELDS = {
|
||||
heading: [{ prop: 'text', label: 'Überschrift', max: 2000, rich: 'heading' }],
|
||||
text: [{ prop: 'text', label: 'Text', max: 20000, rich: 'text', multiline: true }],
|
||||
button: [{ prop: 'text', label: 'Beschriftung', max: 300 }],
|
||||
image: [{ prop: 'alt', label: 'Alternativtext', max: 300, multiline: true }, { prop: 'caption', label: 'Bildunterschrift', max: 500, multiline: true }],
|
||||
iconcard: [{ prop: 'title', label: 'Titel', max: 120 }, { prop: 'text', label: 'Text', max: 600, multiline: true }],
|
||||
stat: [{ prop: 'value', label: 'Wert', max: 16 }, { prop: 'label', label: 'Beschriftung', max: 80 }],
|
||||
quote: [{ prop: 'text', label: 'Zitat', max: 1000, multiline: true }, { prop: 'author', label: 'Name', max: 120 }, { prop: 'role', label: 'Rolle', max: 120 }],
|
||||
cta: [{ prop: 'title', label: 'Titel', max: 160 }, { prop: 'text', label: 'Text', max: 600, multiline: true }, { prop: 'buttonText', label: 'Button', max: 80 }],
|
||||
contact: [{ prop: 'address', label: 'Anschrift', max: 200, multiline: true }],
|
||||
accordionitem: [{ prop: 'title', label: 'Titel', max: 160 }],
|
||||
tabitem: [{ prop: 'title', label: 'Titel', max: 160 }],
|
||||
legal: [{ prop: 'label', label: 'Beschriftung für Vorlesesoftware', max: 60 }],
|
||||
postnav: [{ prop: 'previousLabel', label: 'Beschriftung zurück', max: 60 }, { prop: 'nextLabel', label: 'Beschriftung weiter', max: 60 }],
|
||||
icon: [{ prop: 'label', label: 'Beschriftung', max: 120 }],
|
||||
progress: [{ prop: 'label', label: 'Beschriftung', max: 120 }],
|
||||
embed: [{ prop: 'title', label: 'Titel', max: 160 }, { prop: 'hint', label: 'Hinweis', max: 300, multiline: true }],
|
||||
};
|
||||
|
||||
/** Felder der Seite selbst. `seoTitle` hat kein Gegenstück im Dokument der Hauptsprache. */
|
||||
export const PAGE_FIELDS = [
|
||||
{ key: 'title', label: 'Seitentitel', max: 160 },
|
||||
{ key: 'description', label: 'Beschreibung für Suchmaschinen', max: 320, multiline: true },
|
||||
];
|
||||
|
||||
/**
|
||||
* Der Vermerk, wie ein Text der Hauptsprache beim Übersetzen aussah: FNV-1a über UTF-8, 32 Bit,
|
||||
* als acht Hexziffern - dasselbe wie hash('fnv1a32') in PHP (Translations::fingerprint).
|
||||
*/
|
||||
export function fingerprint(text) {
|
||||
let hash = 0x811c9dc5;
|
||||
for (const byte of new TextEncoder().encode(text)) {
|
||||
hash ^= byte;
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
return hash.toString(16).padStart(8, '0');
|
||||
}
|
||||
|
||||
const empty = value => typeof value !== 'string' || value.trim() === '';
|
||||
|
||||
/** Das sichtbare Wort eines formatierten Texts, für Überschriften in der Liste. */
|
||||
export function plainText(markup) {
|
||||
const node = document.createElement('template');
|
||||
node.innerHTML = markup ?? '';
|
||||
return node.content.textContent.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle übersetzbaren Texte des Dokuments in Seitenreihenfolge, je mit Original, Übersetzung und
|
||||
* Zustand: "missing" (fehlt), "stale" (Original seitdem geändert) oder "done".
|
||||
*
|
||||
* @return {Array<{key: string, blockId: string|null, prop: string, label: string, block: object|null, original: string, value: string, state: string, field: object}>}
|
||||
*/
|
||||
export function entries(document, locale) {
|
||||
const translation = document.translations?.[locale] ?? {};
|
||||
const list = [];
|
||||
const push = (key, blockId, prop, field, original, value, block = null) => {
|
||||
if (empty(original)) return;
|
||||
const hash = translation.source?.[key];
|
||||
const state = empty(value) ? 'missing' : hash && hash !== fingerprint(original) ? 'stale' : 'done';
|
||||
list.push({ key, blockId, prop, label: field.label, block, original, value: value ?? '', state, field });
|
||||
};
|
||||
for (const field of PAGE_FIELDS) push(field.key, null, field.key, field, document[field.key], translation[field.key]);
|
||||
const walk = blocks => {
|
||||
for (const block of blocks ?? []) {
|
||||
for (const field of FIELDS[block.type] ?? []) {
|
||||
// Ein dekoratives Bild oder Symbol hat keinen Text, also auch keinen zu übersetzen.
|
||||
if (['alt', 'label'].includes(field.prop) && block.props?.decorative === true) continue;
|
||||
push(`${block.id}.${field.prop}`, block.id, field.prop, field, block.props?.[field.prop], translation.blocks?.[block.id]?.[field.prop], block);
|
||||
}
|
||||
walk(block.children);
|
||||
}
|
||||
};
|
||||
walk(document.blocks);
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Zählt, was übersetzt ist: { total, done, stale }. */
|
||||
export function progress(document, locale) {
|
||||
const list = entries(document, locale);
|
||||
return { total: list.length, done: list.filter(entry => entry.state !== 'missing').length, stale: list.filter(entry => entry.state === 'stale').length };
|
||||
}
|
||||
|
||||
/** Die Übersetzung einer Sprache, angelegt, falls es sie noch nicht gibt. */
|
||||
function ensure(document, locale) {
|
||||
upgradeDocument(document, 27);
|
||||
document.translations ??= {};
|
||||
return (document.translations[locale] ??= {});
|
||||
}
|
||||
|
||||
/** Räumt eine Sprache auf, die nichts mehr enthält, damit das Dokument nicht wächst. */
|
||||
function tidy(document, locale) {
|
||||
const translation = document.translations?.[locale];
|
||||
if (!translation) return;
|
||||
for (const [id, fields] of Object.entries(translation.blocks ?? {})) if (!Object.keys(fields).length) delete translation.blocks[id];
|
||||
if (translation.blocks && !Object.keys(translation.blocks).length) delete translation.blocks;
|
||||
if (translation.source && !Object.keys(translation.source).length) delete translation.source;
|
||||
if (!Object.keys(translation).length) delete document.translations[locale];
|
||||
if (document.translations && !Object.keys(document.translations).length) delete document.translations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt einen übersetzten Text. Mit dem Text wird vermerkt, wie das Original gerade aussieht -
|
||||
* ändert es sich später, gilt die Übersetzung als veraltet. Leer heißt: nicht übersetzt.
|
||||
* Läuft innerhalb einer Transaktion der History.
|
||||
*/
|
||||
export function setText(document, locale, key, value) {
|
||||
const translation = ensure(document, locale);
|
||||
const [blockId, prop] = key.includes('.') ? key.split('.') : [null, key];
|
||||
const original = blockId ? locate(document.blocks, blockId)?.block?.props?.[prop] : document[prop];
|
||||
if (blockId) {
|
||||
translation.blocks ??= {};
|
||||
const fields = (translation.blocks[blockId] ??= {});
|
||||
if (empty(value)) delete fields[prop]; else fields[prop] = value;
|
||||
} else if (empty(value)) delete translation[prop]; else translation[prop] = value;
|
||||
translation.source ??= {};
|
||||
if (empty(value) || typeof original !== 'string') delete translation.source[key];
|
||||
else translation.source[key] = fingerprint(original);
|
||||
tidy(document, locale);
|
||||
}
|
||||
|
||||
/** Bestätigt eine veraltete Übersetzung, ohne sie zu ändern: das Original ist so gemeint. */
|
||||
export function confirmText(document, locale, key) {
|
||||
const translation = document.translations?.[locale];
|
||||
const [blockId, prop] = key.includes('.') ? key.split('.') : [null, key];
|
||||
const original = blockId ? locate(document.blocks, blockId)?.block?.props?.[prop] : document[prop];
|
||||
if (!translation || typeof original !== 'string') return;
|
||||
(translation.source ??= {})[key] = fingerprint(original);
|
||||
}
|
||||
|
||||
/** Adresse und Suchtitel einer Sprache. Leer heißt: wie in der Hauptsprache beziehungsweise wie der Titel. */
|
||||
export function setPageValue(document, locale, key, value) {
|
||||
const translation = ensure(document, locale);
|
||||
if (empty(value)) delete translation[key]; else translation[key] = key === 'slug' ? value.trim().toLowerCase() : value;
|
||||
tidy(document, locale);
|
||||
}
|
||||
|
||||
/** Aus einem Titel eine Adresse: Kleinbuchstaben, Ziffern, einzelne Bindestriche. */
|
||||
export function slugify(text) {
|
||||
return plainText(text).toLowerCase()
|
||||
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
|
||||
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80).replace(/-+$/g, '');
|
||||
}
|
||||
|
||||
/** Kurzer Name eines Blocks für die Liste: Art und der Anfang seines Originaltexts. */
|
||||
export function describeBlock(block) {
|
||||
return blockLabel(block);
|
||||
}
|
||||
+17
-6
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
use App\Ops\Notfall;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
// PHP nennt in jeder Antwort seine genaue Fassung („X-Powered-By: PHP/8.4.22"). Das nützt
|
||||
@@ -8,9 +9,19 @@ use Symfony\Component\HttpFoundation\Request;
|
||||
// Hosting laesst sich expose_php nicht umstellen, dieser Aufruf wirkt trotzdem.
|
||||
header_remove('X-Powered-By');
|
||||
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
|
||||
$request = Request::createFromGlobals();
|
||||
$response = $kernel->handle($request);
|
||||
$response->send();
|
||||
$kernel->terminate($request, $response);
|
||||
// Die Notfallschicht zuerst und ohne Autoloader (ADR 113): Scheitert alles Weitere - fehlende
|
||||
// Abhängigkeiten, eine unlesbare Datenbank, ein Fehler beim Start -, sehen Besucher eine
|
||||
// Wartungsseite statt einer weißen Seite, und der Grund steht in var/log/notfall.log.
|
||||
require_once dirname(__DIR__).'/src/Ops/Notfall.php';
|
||||
Notfall::einschalten(dirname(__DIR__));
|
||||
|
||||
try {
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']);
|
||||
$request = Request::createFromGlobals();
|
||||
$response = $kernel->handle($request);
|
||||
$response->send();
|
||||
$kernel->terminate($request, $response);
|
||||
} catch (Throwable $fehler) {
|
||||
Notfall::ausnahme($fehler, $_SERVER['REQUEST_URI'] ?? '');
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Legt auf der bestehenden DEEG-Installation die Seite „Angebote“ an und verweist von der
|
||||
* Navigation und vom Angebote-Band der Startseite dorthin.
|
||||
*
|
||||
* Die Seite zeigt die Ergebnisliste des DEEG-Portals bei ImmobilienScout24 als eingebetteten
|
||||
* Rahmen – Baustein „Externer Inhalt“, nicht eigenes HTML: nur er fragt vorher um Einwilligung
|
||||
* und trägt den Anbieter in die Sicherheitsrichtlinie der Seite ein. Kopfabschnitt, Schriftgrößen
|
||||
* und Farben sind dieselben wie bei Impressum und Datenschutz (siehe seed-immobilien-deeg.php).
|
||||
*
|
||||
* Läuft nur gegen die DEEG-Datenbank und nur, solange es die Seite noch nicht gibt.
|
||||
*/
|
||||
require dirname(__DIR__).'/config/bootstrap.php';
|
||||
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', $root), (string) $_SERVER['DATABASE_URL']);
|
||||
if (!str_contains($url, 'immobilien-deeg')) {
|
||||
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die DEEG-Datenbank zeigen.\n");
|
||||
exit(1);
|
||||
}
|
||||
$db = ConnectionFactory::create($url);
|
||||
$media = new MediaLibrary($db, str_replace('%kernel.project_dir%', $root, (string) $_SERVER['MEDIA_DIRECTORY']));
|
||||
$settings = new SiteSettings($db, $media);
|
||||
$pages = new PageRepository($db, new DocumentValidator(), $settings);
|
||||
$menus = new MenuRepository($db, $settings);
|
||||
|
||||
// Die Seite war im Editor schon angelegt (Starterabschnitt plus Rahmen mit Platzhaltertexten).
|
||||
// Sie wird überschrieben, nicht verdoppelt; der bisherige Stand bleibt als Fassung erhalten und
|
||||
// lässt sich im Editor unter „Fassungen“ zurückholen.
|
||||
$vorhanden = $db->fetchOne('SELECT id FROM page WHERE path = ?', ['angebote']);
|
||||
|
||||
$SCHEMA = 24;
|
||||
$portal = 'https://portal.immobilienscout24.de/ergebnisliste/16200281';
|
||||
$nurUnten = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => $w, 'left' => '0px'];
|
||||
|
||||
// Der Kopf wie auf Impressum und Datenschutz: helle Fläche, Kleinzeile, große leichte Überschrift.
|
||||
$kopf = ['id' => 'angebote-kopf', 'type' => 'section', 'children' => [
|
||||
['id' => 'angebote-label', 'type' => 'text', 'props' => ['text' => 'AKTUELLE OBJEKTE'], 'style' => [
|
||||
'desktop' => ['fontSize' => '12px', 'fontWeight' => '700', 'letterSpacing' => '0.14em', 'lineHeight' => 1.6, 'color' => '@anthrazit', 'margin' => ['bottom' => '25px']],
|
||||
'mobile' => ['fontSize' => '11px', 'margin' => ['bottom' => '18px']],
|
||||
], 'layout' => ['effects' => ['entrance' => 'fade', 'delay' => 'none']]],
|
||||
['id' => 'angebote-h1', 'type' => 'heading', 'props' => ['text' => 'Unsere Angebote', 'level' => 1], 'style' => [
|
||||
'desktop' => ['fontWeight' => '400', 'fontSize' => '56px', 'lineHeight' => 1.1, 'letterSpacing' => '-0.05em', 'margin' => ['bottom' => '22px']],
|
||||
'tablet' => ['fontSize' => '44px'],
|
||||
'mobile' => ['fontSize' => '38px', 'margin' => ['bottom' => '16px']],
|
||||
], 'layout' => ['effects' => ['entrance' => 'rise', 'delay' => 'short']]],
|
||||
['id' => 'angebote-vorspann', 'type' => 'text', 'props' => ['text' => 'Wohnungen, Häuser und Grundstücke aus unserem Bestand in Freudenstadt und Umgebung. Die Liste wird laufend aktualisiert – sehen Sie sich um und sprechen Sie uns an.'], 'style' => [
|
||||
'desktop' => ['maxWidth' => '640px', 'fontSize' => '18px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||
'mobile' => ['fontSize' => '16px'],
|
||||
], 'layout' => ['effects' => ['entrance' => 'rise', 'delay' => 'medium']]],
|
||||
], 'style' => [
|
||||
'desktop' => ['backgroundColor' => '@hell', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie-hell', 'padding' => ['top' => '84px', 'bottom' => '72px']],
|
||||
'tablet' => ['padding' => ['top' => '64px', 'bottom' => '56px']],
|
||||
'mobile' => ['padding' => ['top' => '48px', 'bottom' => '40px']],
|
||||
]];
|
||||
|
||||
// Die Liste selbst. Der Rahmen bleibt hoch genug, dass mehrere Objekte ohne Scrollen im Rahmen
|
||||
// sichtbar sind; darunter der Weg zum Portal für alle, die lieber dort suchen.
|
||||
$hinweisText = 'Nichts Passendes dabei? <a href="'.$portal.'" target="_blank" rel="noopener">Alle Objekte direkt bei ImmobilienScout24 ansehen ↗</a> oder <a href="/#kontakt">sprechen Sie uns an</a> – wir kennen den Markt vor Ort und wissen oft von Objekten, bevor sie inseriert sind.';
|
||||
$liste = ['id' => 'angebote-liste', 'type' => 'section', 'children' => [
|
||||
['id' => 'angebote-rahmen', 'type' => 'embed', 'props' => [
|
||||
'group' => 'external',
|
||||
'title' => 'Aktuelle Immobilienangebote der Deeg Hausverwaltung und Immobilien GmbH bei ImmobilienScout24',
|
||||
'provider' => 'ImmobilienScout24',
|
||||
'url' => $portal,
|
||||
'hint' => 'Die Angebotsliste wird von ImmobilienScout24 geladen. Dabei erfährt der Anbieter Ihre IP-Adresse und welche Seite Sie ansehen.',
|
||||
'height' => 1000,
|
||||
], 'style' => ['desktop' => ['margin' => ['bottom' => '26px']]], 'layout' => ['effects' => ['entrance' => 'fade', 'delay' => 'none']],
|
||||
// Der Rahmen selbst und der Platzhalter davor tragen den Zuschnitt der Vorlage: eckig,
|
||||
// helle Fläche, Logo-Blau auf dem Knopf. Was IM Rahmen steht, gehört ImmobilienScout24 -
|
||||
// daran kommt kein CSS dieser Website heran, und das ist keine Einstellung, sondern die
|
||||
// Trennung zwischen zwei Herkünften im Browser.
|
||||
'css' => "& iframe{border:1px solid #dbe1e3;border-radius:0;background:#f5fafd}
|
||||
&.embed-blocked{border:1px solid #dbe1e3;border-radius:0;background:#f5fafd;padding:56px 24px;text-align:left}
|
||||
& .embed-title{font-size:20px;font-weight:700;color:#343a40;margin-bottom:12px}
|
||||
& .embed-hint{max-width:640px;font-size:15px;line-height:1.7;color:#58666d}
|
||||
& .embed-provider{font-size:13px;color:#66747b}
|
||||
& .consent-button{border-radius:0;background:#0076b6;border-color:#0076b6;color:#fff;flex:0 0 auto;padding:16px 24px}
|
||||
& .consent-button:hover{background:#006aa3;border-color:#006aa3}",
|
||||
],
|
||||
['id' => 'angebote-hinweis', 'type' => 'text', 'props' => ['text' => $hinweisText], 'style' => [
|
||||
'desktop' => ['maxWidth' => '760px', 'fontSize' => '16px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||
], 'css' => "& a{color:#006aa3;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#343a40}",
|
||||
'layout' => ['effects' => ['entrance' => 'rise', 'delay' => 'short']]],
|
||||
], 'style' => [
|
||||
'desktop' => ['padding' => ['top' => '72px', 'bottom' => '108px']],
|
||||
'tablet' => ['padding' => ['top' => '56px', 'bottom' => '80px']],
|
||||
'mobile' => ['padding' => ['top' => '40px', 'bottom' => '65px']],
|
||||
]];
|
||||
|
||||
$dokumentAngebote = [
|
||||
'schemaVersion' => $SCHEMA,
|
||||
'title' => 'Angebote',
|
||||
'description' => 'Aktuelle Wohnungen, Häuser und Grundstücke der Deeg Hausverwaltung und Immobilien GmbH in Freudenstadt und Umgebung.',
|
||||
'blocks' => [$kopf, $liste],
|
||||
];
|
||||
if (false !== $vorhanden) {
|
||||
$id = (string) $vorhanden;
|
||||
$revision = $pages->saveDraft($id, $dokumentAngebote, (int) $pages->findForEditor($id)['version']);
|
||||
$pages->publish($id, $revision, (int) $pages->findForEditor($id)['version']);
|
||||
echo "Seite überarbeitet und veröffentlicht: /angebote\n";
|
||||
} else {
|
||||
$id = $pages->createPage('angebote', null, 'page', $dokumentAngebote);
|
||||
$seite = $db->fetchAssociative('SELECT draft_revision_id, version FROM page WHERE id = ?', [$id]);
|
||||
$pages->publish($id, $seite['draft_revision_id'], (int) $seite['version']);
|
||||
echo "Seite angelegt und veröffentlicht: /angebote\n";
|
||||
}
|
||||
$db->executeStatement('UPDATE page SET seo_title = ? WHERE id = ?', ['Aktuelle Angebote | Deeg Hausverwaltung und Immobilien GmbH', $id]);
|
||||
|
||||
// „Immobilien“ in der Navigation zeigte auf den Sprungpunkt der Startseite; jetzt gibt es die
|
||||
// Seite, auf die dieser Abschnitt ohnehin nur verwiesen hat.
|
||||
$header = $menus->all()['header'];
|
||||
$eintraege = [];
|
||||
foreach ($header['items'] as $eintrag) {
|
||||
if ('Immobilien' === $eintrag['label']) {
|
||||
$eintrag['pageId'] = $id;
|
||||
$eintrag['anchor'] = '';
|
||||
}
|
||||
$eintraege[] = $eintrag;
|
||||
}
|
||||
$menus->save('header', $header['name'], $eintraege, (int) $header['version']);
|
||||
echo "Navigation: „Immobilien“ zeigt auf /angebote\n";
|
||||
|
||||
// Und der Knopf im Angebote-Band der Startseite, der bisher auf die alte Website führte.
|
||||
$home = $db->fetchAssociative('SELECT id FROM page WHERE path = ?', ['home']);
|
||||
$dokument = $pages->findForEditor($home['id'])['document'];
|
||||
$getroffen = 0;
|
||||
$walk = function (array &$blocks) use (&$walk, &$getroffen): void {
|
||||
foreach ($blocks as &$block) {
|
||||
// Der Knopf im Angebote-Band ist ein Textbaustein mit eigenem CSS, kein Button-Baustein -
|
||||
// deshalb wird die Adresse im Text ersetzt, nicht in den Eigenschaften.
|
||||
foreach (['href', 'text'] as $feld) {
|
||||
$wert = (string) ($block['props'][$feld] ?? '');
|
||||
if ('' !== $wert && str_contains($wert, 'immobiliendeeg.de/immobilien')) {
|
||||
$block['props'][$feld] = str_replace(['https://www.immobiliendeeg.de/immobilien/', 'https://www.immobiliendeeg.de/immobilien'], '/angebote', $wert);
|
||||
unset($block['props']['newTab']);
|
||||
++$getroffen;
|
||||
}
|
||||
}
|
||||
if (isset($block['children']) && is_array($block['children'])) {
|
||||
$walk($block['children']);
|
||||
}
|
||||
}
|
||||
};
|
||||
$walk($dokument['blocks']);
|
||||
if ($getroffen > 0) {
|
||||
$stand = $pages->findForEditor($home['id']);
|
||||
$revision = $pages->saveDraft($home['id'], $dokument, $stand['version']);
|
||||
$pages->publish($home['id'], $revision, $pages->findForEditor($home['id'])['version']);
|
||||
}
|
||||
echo "Startseite: $getroffen Knopf/Knöpfe zeigen jetzt auf /angebote\n";
|
||||
|
||||
// Ein fremder Rahmen ohne Einwilligung lädt beim Seitenaufruf mit und gibt dabei IP-Adresse und
|
||||
// Seite an den Anbieter weiter. Mit der Gruppe „Externe Inhalte“ steht davor der Platzhalter, und
|
||||
// erst der Knopf lädt die Liste. Der Balken trägt die Farben der Website.
|
||||
$aktuell = $settings->load();
|
||||
$settings->save([
|
||||
...$aktuell,
|
||||
// Gespeichert als Liste, erwartet als Textfeld - wie es der Einstellungsbildschirm schickt.
|
||||
'sameAs' => implode("\n", $aktuell['sameAs']),
|
||||
'consentBanner' => true,
|
||||
'consentGroups' => ['external'],
|
||||
'consentShape' => 'eckig',
|
||||
'consentAccent' => '@blau-tief',
|
||||
'consentAccentInk' => '@papier',
|
||||
'consentColor' => '@papier',
|
||||
'consentInk' => '@anthrazit',
|
||||
]);
|
||||
echo "Einwilligung eingeschaltet: Gruppe „Externe Inhalte“, Balken eckig in Logo-Blau\n";
|
||||
@@ -46,8 +46,9 @@ try {
|
||||
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||
await page.getByRole('button', { name: 'Anmelden', exact: true }).click();
|
||||
await page.waitForURL(origin + '/admin'); await page.goto(origin + '/admin/forms');
|
||||
await page.locator('.form-list a').first().click(); await page.locator('.form-def').waitFor();
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.locator('.field-table tbody tr').first().screenshot({ path: 'reports/admin-quality/form-field-mobile.png' });
|
||||
await page.locator('.field-card').first().screenshot({ path: 'reports/admin-quality/form-field-mobile.png' });
|
||||
console.log('Public no-JavaScript baseline and visual captures passed.');
|
||||
} else {
|
||||
if (!process.argv.includes('--admin-only')) {
|
||||
@@ -72,13 +73,19 @@ try {
|
||||
await page.mouse.move(1, 1); await page.waitForTimeout(7200);
|
||||
console.log('Paused slider:', JSON.stringify(await geometry()), await controls.innerText());
|
||||
assert.ok(Math.abs((await geometry()).left - manual.left) < 2, 'manual navigation pauses autoplay');
|
||||
await controls.getByRole('button', { name: 'Automatische Wiedergabe starten' }).click();
|
||||
await controls.getByRole('button', { name: 'Wiedergabe: automatisch weiterblättern' }).click();
|
||||
await page.mouse.move(1, 1); await page.waitForTimeout(7700);
|
||||
assert.ok(Math.abs((await geometry()).left - manual.left) > 20, 'play resumes after manual operation');
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
assert.equal(await controls.getByRole('button', { name: 'Automatische Wiedergabe starten' }).isDisabled(), true);
|
||||
// Seit 1.11 entscheidet die Website (Einstellungen → Bewegung): Standard ist „immer abspielen“,
|
||||
// nur mit „Systemvorgabe beachten“ (Klasse motion-respect) hält „Bewegung reduzieren“ die
|
||||
// Automatik an. Geprüft wird das Verhalten, das die Einstellung verspricht.
|
||||
const respects = await page.evaluate(() => document.documentElement.classList.contains('motion-respect'));
|
||||
const play = controls.locator('.slider-play');
|
||||
assert.equal(await play.isDisabled(), respects, 'the play button follows the motion setting');
|
||||
const reduced = await geometry(); await page.waitForTimeout(7100);
|
||||
assert.ok(Math.abs((await geometry()).left - reduced.left) < 2, 'reduced motion stops autoplay');
|
||||
if (respects) assert.ok(Math.abs((await geometry()).left - reduced.left) < 2, 'reduced motion stops autoplay when the site respects it');
|
||||
else assert.ok(Math.abs((await geometry()).left - reduced.left) > 20, 'autoplay keeps running when the site plays motion always');
|
||||
await controls.getByRole('button', { name: 'Vorherige Folie', exact: true }).click();
|
||||
assert.ok(Math.abs((await geometry()).left - reduced.left) > 20, 'manual navigation works with reduced motion');
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
@@ -143,7 +150,7 @@ try {
|
||||
await page.getByRole('button', { name: 'Link hinzufügen', exact: true }).click();
|
||||
assert.equal((await menuSave()).status(), 422);
|
||||
assert.equal(await entries.last().locator('[data-key="url"]').inputValue(), 'javascript:alert(1)');
|
||||
assert.equal(await page.locator('#menu-error').isVisible(), true);
|
||||
await page.locator('#menu-error').waitFor({ state: 'visible' });
|
||||
await entries.last().locator('summary').click();
|
||||
await entries.last().getByLabel('Adresse', { exact: true }).fill('/#kontakt');
|
||||
assert.equal((await menuSave()).status(), 200);
|
||||
@@ -155,15 +162,22 @@ try {
|
||||
await page.getByLabel('Name des Menüs').fill('QA lokaler Stand');
|
||||
assert.equal((await menuSave()).status(), 409);
|
||||
assert.equal(await page.getByLabel('Name des Menüs').inputValue(), 'QA lokaler Stand');
|
||||
assert.equal(await page.locator('#menu-error').isVisible(), true);
|
||||
await page.locator('#menu-error').waitFor({ state: 'visible' });
|
||||
checks.push({ name: 'menu search by keyboard, add/nest/remove/undo/save, invalid URL and conflict retain input', passed: true });
|
||||
|
||||
await page.goto(origin + '/admin/forms');
|
||||
await page.locator('.form-list a').first().click();
|
||||
const form = page.locator('.form-def').first(), message = form.locator('[name="success"]');
|
||||
// Der Abschnitt „Zustellung“ ist zugeklappt, bis man ihn öffnet.
|
||||
const openDelivery = async () => {
|
||||
await form.locator('details.form-section').first().evaluate(section => { section.open = true; });
|
||||
};
|
||||
await openDelivery();
|
||||
const widths = await form.evaluate(form => ({ message: form.querySelector('[name="success"]').clientWidth, fields: form.querySelector('.fields').clientWidth }));
|
||||
assert.ok(widths.message >= widths.fields - 5, 'success message uses available form width');
|
||||
await message.fill('Vielen Dank für deine Nachricht.\nWir melden uns persönlich bei dir.');
|
||||
await form.getByRole('button', { name: 'Formular speichern', exact: true }).click();
|
||||
await page.waitForLoadState('load'); await openDelivery();
|
||||
assert.equal(await message.inputValue(), 'Vielen Dank für deine Nachricht.\nWir melden uns persönlich bei dir.');
|
||||
checks.push({ name: 'wide multiline form response persists', passed: true });
|
||||
|
||||
@@ -172,7 +186,10 @@ try {
|
||||
await page.getByLabel('Name', { exact: true }).fill('QA Gemeinsame Sektion');
|
||||
await page.getByRole('button', { name: 'Anlegen und bearbeiten', exact: true }).click();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
// Die Sektion im Reiter „Struktur“ wählen, dann den Text aus „Elemente“ hineinsetzen.
|
||||
await page.getByRole('tab', { name: 'Struktur', exact: true }).click();
|
||||
await page.locator('#structure .tree-item').first().click();
|
||||
await page.getByRole('tab', { name: 'Elemente', exact: true }).click();
|
||||
await page.locator('#library [data-add="text"]').click();
|
||||
await page.getByLabel('Text', { exact: true }).fill('Dieser Inhalt wird als gemeinsame Sektion gespeichert.');
|
||||
await page.getByLabel('Text', { exact: true }).press('Tab');
|
||||
@@ -197,7 +214,7 @@ try {
|
||||
|
||||
await page.goto(origin + '/admin/pages/' + fixture.homeId);
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Bewegung testen', exact: true }).click();
|
||||
await page.locator('#preview-motion').click();
|
||||
const preview = page.frameLocator('#preview'), previewSlider = preview.locator('#hero-slider');
|
||||
// Measure in the outer page: Playwright's in-frame animation stability check can stall in
|
||||
// a no-script sandbox while a Web Animation runs. Use a real pointer on verified geometry.
|
||||
@@ -210,7 +227,10 @@ try {
|
||||
await page.waitForTimeout(800);
|
||||
assert.ok(await previewSlider.evaluate(slider => slider.scrollLeft > 20));
|
||||
assert.equal(await page.locator('#preview').getAttribute('sandbox'), 'allow-same-origin');
|
||||
await page.getByRole('button', { name: 'Bewegung stoppen', exact: true }).click();
|
||||
// Derselbe Knopf stoppt wieder; er sagt es in seiner Beschriftung und in aria-pressed.
|
||||
assert.equal(await page.locator('#preview-motion').getAttribute('aria-label'), 'Bewegung in der Vorschau stoppen');
|
||||
await page.locator('#preview-motion').click();
|
||||
assert.equal(await page.locator('#preview-motion').getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(await preview.locator('.slider-controls').count(), 0);
|
||||
checks.push({ name: 'sandboxed editor preview has opt-in working slider controls', passed: true });
|
||||
|
||||
|
||||
+3
-1
@@ -18,7 +18,9 @@ const portProbe = net.createServer();
|
||||
await new Promise(resolve => portProbe.listen(0, '127.0.0.1', resolve));
|
||||
const port = portProbe.address().port;
|
||||
await new Promise(resolve => portProbe.close(resolve));
|
||||
const chrome = await chromium.launch({ args: [`--remote-debugging-port=${port}`] });
|
||||
// CMS_HOST_RULES, etwa "MAP localhost 127.0.0.1": für eingebettete Seiten, die das Einbetten nur
|
||||
// von localhost erlauben, während der Server auf 127.0.0.1 lauscht (Kantarci-Markt-App).
|
||||
const chrome = await chromium.launch({ args: [`--remote-debugging-port=${port}`, ...(process.env.CMS_HOST_RULES ? [`--host-resolver-rules=${process.env.CMS_HOST_RULES}`] : [])] });
|
||||
const summary = [];
|
||||
try {
|
||||
for (const path of paths) {
|
||||
|
||||
@@ -80,10 +80,13 @@ try {
|
||||
assert.ok(html < 150000, `${path}: HTML ${html} Bytes, Obergrenze 150000`);
|
||||
assert.ok(styles < 120000, `${path}: CSS ${styles} Bytes, Obergrenze 120000`);
|
||||
// Eine öffentliche Seite lädt kein Skript, solange sie keines braucht - und das steht in
|
||||
// ihrem eigenen Kopf: nur wenn die Seite Skripte erlaubt (Reiter, Slider mit Autolauf,
|
||||
// eigener Code), darf überhaupt eines kommen, dann höchstens eines.
|
||||
// ihrem eigenen Kopf: nur wenn die Seite Skripte erlaubt (Reiter, Slider, Einblendungen,
|
||||
// Formular mit Schritten, eigener Code), darf überhaupt eines kommen. Jede Fähigkeit
|
||||
// bringt genau eine kleine Datei mit; drei davon sind die Grenze, und alle vom eigenen Ursprung.
|
||||
const erlaubt = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
|
||||
assert.ok(erlaubt ? scripts <= 1 : 0 === scripts, `${path}: ${scripts} Skript(e) geladen, erlaubt ${erlaubt ? 1 : 0}`);
|
||||
assert.ok(erlaubt ? scripts <= 3 : 0 === scripts, `${path}: ${scripts} Skript(e) geladen, erlaubt ${erlaubt ? 3 : 0}`);
|
||||
const fremd = [...transferred.keys()].filter(url => (transferred.get(url).type.includes('javascript')) && !url.startsWith(origin + '/assets/'));
|
||||
assert.deepEqual(fremd, [], `${path}: Skript von außerhalb /assets/`);
|
||||
assert.ok(beiwerk <= 12, `${path}: ${beiwerk} Anfragen ohne Bilder, Obergrenze 12`);
|
||||
assert.ok(gewicht < 1500000, `${path}: ${gewicht} Bytes übertragen, Obergrenze 1500000`);
|
||||
if (queries >= 0) assert.ok(queries <= 20, `${path}: ${queries} Datenbankabfragen, Obergrenze 20`);
|
||||
@@ -113,7 +116,12 @@ try {
|
||||
// wird, was laufen kann - und das nur, wenn die Seite es in ihrer eigenen Richtlinie erlaubt.
|
||||
const laufend = await page.locator('script:not([type="application/ld+json"])').count();
|
||||
const darfSkripte = !/script-src 'none'/.test(response.headers()['content-security-policy'] ?? '');
|
||||
assert.ok(darfSkripte ? laufend <= 1 : 0 === laufend, `${path}: ${laufend} Skript(e), erlaubt ${darfSkripte ? 1 : 0}`);
|
||||
assert.ok(darfSkripte ? laufend <= 3 : 0 === laufend, `${path}: ${laufend} Skript(e), erlaubt ${darfSkripte ? 3 : 0}`);
|
||||
// Einblendungen zu Ende spielen, bevor gemessen wird: mitten in einer Einblendung ist ein
|
||||
// Block halb durchsichtig oder verschoben. axe rechnet den Kontrast dann gegen die
|
||||
// verwaschene Farbe, und ein von der Seite hereingleitender Block zählt als Überlauf -
|
||||
// beides Befunde, die kein Besucher je sieht.
|
||||
await page.evaluate(() => document.getAnimations().forEach(animation => animation.finish()));
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), 'Horizontal overflow');
|
||||
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa', 'best-practice']).analyze();
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { readFile, writeFile } from 'node:fs/promises';
|
||||
*/
|
||||
export function designCss(tokens) {
|
||||
const rules = device => Object.entries(tokens).flatMap(([key, token]) => Object.entries(token.values).map(([value, [, css]]) => `.kant .d-${device}-${key}-${value}{${css}}`)).join('\n');
|
||||
return `/* Generated from design-tokens.json by npm run build:design. */\n${rules('desktop')}\n@media(max-width:1024px){\n${rules('tablet')}\n}\n@media(max-width:650px){\n${rules('mobile')}\n}\n`;
|
||||
return `/* Generated from design-tokens.json by npm run build:design. */\n${rules('desktop')}\n@media(max-width:1024px){\n${rules('tablet')}\n}\n@media(max-width:767px){\n${rules('mobile')}\n}\n`;
|
||||
}
|
||||
if (process.argv[1]?.replaceAll('\\', '/').endsWith('/build-design.mjs')) {
|
||||
const tokens = JSON.parse(await readFile(new URL('../public/assets/design-tokens.json', import.meta.url), 'utf8'));
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Zeichnet die Bildkarten des Solarchecks von EB Solar und rastert sie zu PNG.
|
||||
*
|
||||
* Die Formularbausteine zeigen eine Auswahl als Bildkarten, sobald eine Option ein Bild aus der
|
||||
* Mediathek hat - und die Mediathek nimmt JPEG, PNG und WebP, kein SVG. Deshalb entstehen die
|
||||
* Symbole hier als SVG (leicht zu ändern, alles in einer Datei) und werden mit Chromium auf
|
||||
* 800 × 600 gerastert, dem Seitenverhältnis 4:3 der Karte. Stil wie die übrigen Symbole der
|
||||
* Website: dünne Linie in Tinte, runde Enden, ein roter Akzent, helle Fläche dahinter.
|
||||
*
|
||||
* node scripts/build-ebsolar-options.mjs → var/ebsolar-assets/options/*.png und *.svg
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
|
||||
const ziel = process.argv[2] || 'var/ebsolar-assets/options';
|
||||
const TINTE = '#252b30', ROT = '#b52c2c', HELL = '#f5f6f6', PAPIER = '#ffffff', GRAU = '#c9ced0';
|
||||
|
||||
/** Ein Bild: 800 × 600, helle Fläche, Inhalt in der Mitte. */
|
||||
const bild = (inhalt) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600">
|
||||
<rect width="800" height="600" fill="${HELL}"/>
|
||||
<g fill="none" stroke="${TINTE}" stroke-width="14" stroke-linecap="round" stroke-linejoin="round">${inhalt}</g>
|
||||
</svg>`;
|
||||
|
||||
/** Eine Person: Kopf und Schultern. */
|
||||
const person = (x, y, s = 1, farbe = TINTE) => `
|
||||
<circle cx="${x}" cy="${y - 120 * s}" r="${46 * s}" stroke="${farbe}"/>
|
||||
<path d="M${x - 88 * s} ${y + 70 * s}v-${30 * s}a${88 * s} ${88 * s} 0 0 1 ${176 * s} 0v${30 * s}" stroke="${farbe}"/>`;
|
||||
|
||||
/** Ein Haus: Wände plus Dach nach Angabe; das Dach ist der Akzent. */
|
||||
const haus = (dach) => `
|
||||
<path d="M190 520V300M610 520V300M150 520h500"/>
|
||||
<rect x="360" y="410" width="80" height="110" rx="6"/>
|
||||
${dach}`;
|
||||
|
||||
/** Ein Dach als schräge Fläche mit Modulen im Raster. */
|
||||
const module = (spalten, zeilen) => {
|
||||
const x0 = 160, y0 = 150, b = 480, h = 300, scher = 90;
|
||||
const zellen = [];
|
||||
for (let z = 0; z < zeilen; z++) {
|
||||
for (let s = 0; s < spalten; s++) {
|
||||
const bx = b / spalten, bh = h / zeilen;
|
||||
const x = x0 + s * bx + (z * bh) * (scher / h), y = y0 + z * bh;
|
||||
zellen.push(`<path d="M${x + 8} ${y + 8}h${bx - 16}l${(bh - 16) * scher / h} ${bh - 16}h-${bx - 16}z" fill="${ROT}" fill-opacity=".14" stroke="${ROT}" stroke-width="8"/>`);
|
||||
}
|
||||
}
|
||||
return `<path d="M${x0} ${y0}h${b}l${scher} ${h}h-${b}z"/>${zellen.join('')}`;
|
||||
};
|
||||
|
||||
const fragezeichen = (x, y, s = 1) => `<path d="M${x - 40 * s} ${y - 50 * s}a${40 * s} ${40 * s} 0 1 1 ${58 * s} ${36 * s}c-${14 * s} ${9 * s}-${18 * s} ${18 * s}-${18 * s} ${34 * s}" stroke="${ROT}"/><circle cx="${x}" cy="${y + 65 * s}" r="9" fill="${ROT}" stroke="none"/>`;
|
||||
|
||||
const batterie = (inhalt, farbe = TINTE) => `
|
||||
<rect x="190" y="200" width="380" height="200" rx="26" stroke="${farbe}"/>
|
||||
<path d="M570 260h30a20 20 0 0 1 20 20v40a20 20 0 0 1-20 20h-30" stroke="${farbe}"/>
|
||||
${inhalt}`;
|
||||
|
||||
const bilder = {
|
||||
// Wie viele Personen leben im Haushalt?
|
||||
'personen-1': person(400, 320, 1.3),
|
||||
'personen-2': person(300, 320, 1.15) + person(500, 320, 1.15),
|
||||
'personen-3-4': person(210, 330) + person(400, 330) + person(590, 330),
|
||||
'personen-4-plus': person(160, 330, .85) + person(320, 330, .85) + person(480, 330, .85) + person(640, 330, .85)
|
||||
+ `<path d="M715 140v70M680 175h70" stroke="${ROT}"/>`,
|
||||
|
||||
// Welche Form hat Ihr Dach?
|
||||
'dach-flach': haus(`<path d="M150 300h500" stroke="${ROT}"/><path d="M200 300v-40h400v40" stroke="${ROT}"/>`),
|
||||
'dach-sattel': haus(`<path d="M140 320 400 120l260 200" stroke="${ROT}"/>`),
|
||||
'dach-pult': haus(`<path d="M150 330 650 190" stroke="${ROT}"/><path d="M610 300v-95" />`),
|
||||
'dach-versetzt': `
|
||||
<path d="M120 520h560M160 520V370M440 520V270M660 520V330"/>
|
||||
<rect x="260" y="420" width="70" height="100" rx="6"/>
|
||||
<path d="M120 380 290 250l150 120" stroke="${ROT}"/><path d="M440 290 560 180l130 140" stroke="${ROT}"/>`,
|
||||
'dach-sonstiges': haus(`<path d="M140 320 400 120l260 200" stroke="${GRAU}"/>`) + fragezeichen(400, 250, 1.1),
|
||||
|
||||
// Wie groß ist Ihre Dachfläche?
|
||||
'flaeche-50': module(2, 1),
|
||||
'flaeche-100': module(3, 2),
|
||||
'flaeche-200': module(5, 3),
|
||||
'flaeche-200-plus': module(7, 4),
|
||||
'flaeche-unsicher': `<path d="M160 150h480l90 300H160z" stroke="${GRAU}"/>` + fragezeichen(400, 300, 1.4),
|
||||
|
||||
// Aus welchem Material besteht die Dacheindeckung?
|
||||
'eindeckung-ziegel': [0, 1, 2, 3].map(z => [0, 1, 2, 3, 4].map(s => {
|
||||
if (z % 2 === 1 && s === 4) return '';
|
||||
const x = 150 + s * 100 + (z % 2) * 50, y = 170 + z * 80;
|
||||
return `<path d="M${x} ${y}a50 50 0 0 0 100 0" stroke="${z === 2 && s === 2 ? ROT : TINTE}"/>`;
|
||||
}).join('')).join(''),
|
||||
'eindeckung-bitumen': `
|
||||
<path d="M130 440h540v50H130z"/>
|
||||
<circle cx="250" cy="360" r="70"/><circle cx="250" cy="360" r="26"/>
|
||||
<path d="M320 400h340" stroke="${ROT}" stroke-width="22"/>
|
||||
<path d="M250 430h410" stroke="${ROT}" stroke-width="22" stroke-opacity=".35"/>`,
|
||||
'eindeckung-blech': `
|
||||
<path d="M120 340h50l30-70h60l30 70h60l30-70h60l30 70h60l30-70h60l30 70h30"/>
|
||||
<path d="M120 420h50l30-70h60l30 70h60l30-70h60l30 70h60l30-70h60l30 70h30" stroke="${GRAU}"/>
|
||||
<path d="M120 340v80M680 340v80" stroke="${GRAU}"/>
|
||||
<path d="M350 340l30-70h60l30 70" stroke="${ROT}"/>`,
|
||||
'eindeckung-anderes': `<rect x="200" y="150" width="400" height="300" rx="20" stroke="${GRAU}"/>` + fragezeichen(400, 300, 1.4),
|
||||
|
||||
// Wünschen Sie einen Stromspeicher?
|
||||
'speicher-ja': batterie(`<path d="M420 230 350 310h60l-30 70 70-80h-60z" fill="${ROT}" stroke="${ROT}" stroke-width="10"/>`),
|
||||
'speicher-nein': batterie('', GRAU) + `<path d="M190 470 610 130" stroke="${ROT}"/>`,
|
||||
};
|
||||
|
||||
await mkdir(ziel, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 800, height: 600 }, deviceScaleFactor: 1 });
|
||||
for (const [name, inhalt] of Object.entries(bilder)) {
|
||||
const svg = bild(inhalt);
|
||||
await writeFile(`${ziel}/${name}.svg`, svg);
|
||||
await page.setContent(`<!doctype html><body style="margin:0;background:${PAPIER}">${svg}</body>`);
|
||||
await page.locator('svg').screenshot({ path: `${ziel}/${name}.png`, omitBackground: false });
|
||||
console.log('gezeichnet:', name);
|
||||
}
|
||||
await browser.close();
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Bereitet die Bilder des Hüfner-Entwurfs für die Mediathek vor.
|
||||
*
|
||||
* Kopiert die Fotos und das Logo aus dem gelieferten Ordner (images/) und schneidet aus dem Logo
|
||||
* den Baum mit den Steinen als quadratisches Seitensymbol (favicon.png) heraus. Der Baum ist im
|
||||
* Logo links, der Schriftzug rechts; ein Favicon mit Schrift wäre in 32 px unlesbar.
|
||||
*
|
||||
* Aufruf: php scripts/build-huefner-assets.php <Quellordner mit images/> <Zielordner>
|
||||
*/
|
||||
$quelle = rtrim($argv[1] ?? '', '/\\');
|
||||
$ziel = rtrim($argv[2] ?? '', '/\\');
|
||||
if ('' === $quelle || '' === $ziel || !is_dir($quelle.'/images')) {
|
||||
fwrite(STDERR, "Aufruf: php scripts/build-huefner-assets.php <Ordner mit images/> <Zielordner>\n");
|
||||
exit(1);
|
||||
}
|
||||
if (!is_dir($ziel) && !mkdir($ziel, 0775, true)) {
|
||||
fwrite(STDERR, "Zielordner lässt sich nicht anlegen: $ziel\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
foreach (glob($quelle.'/images/*.{jpg,png}', GLOB_BRACE) ?: [] as $datei) {
|
||||
copy($datei, $ziel.'/'.basename($datei));
|
||||
}
|
||||
|
||||
// Baum und Steine: im 1600 x 607 px großen Logo etwa x 15–500, y 10–560.
|
||||
$logo = imagecreatefrompng($quelle.'/images/logo.png');
|
||||
if (false === $logo) {
|
||||
fwrite(STDERR, "logo.png lässt sich nicht lesen.\n");
|
||||
exit(1);
|
||||
}
|
||||
$kante = 512;
|
||||
$symbol = imagecreatetruecolor($kante, $kante);
|
||||
imagesavealpha($symbol, true);
|
||||
imagealphablending($symbol, false);
|
||||
imagefill($symbol, 0, 0, imagecolorallocatealpha($symbol, 0, 0, 0, 127));
|
||||
imagealphablending($symbol, true);
|
||||
[$x, $y, $b, $h] = [15, 10, 490, 550];
|
||||
$massstab = min(($kante - 24) / $b, ($kante - 24) / $h);
|
||||
$zb = (int) round($b * $massstab);
|
||||
$zh = (int) round($h * $massstab);
|
||||
imagecopyresampled($symbol, $logo, (int) (($kante - $zb) / 2), (int) (($kante - $zh) / 2), $x, $y, $zb, $zh, $b, $h);
|
||||
imagepng($symbol, $ziel.'/favicon.png');
|
||||
echo "Bilder kopiert, Seitensymbol geschnitten: $ziel\n";
|
||||
@@ -0,0 +1,19 @@
|
||||
// Schreibt den Symbolsatz aus public/assets/icons.js in die Twig-Vorlage der Website und in die
|
||||
// erlaubte Liste des Validators. Eine Quelle, drei Abnehmer; tests/icons.test.mjs prüft das Ergebnis.
|
||||
// Aufruf: node scripts/build-icons.mjs
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { ICON_PATHS } from '../public/assets/icons.js';
|
||||
|
||||
const twigFile = new URL('../templates/blocks/icon.html.twig', import.meta.url);
|
||||
const twig = await readFile(twigFile, 'utf8');
|
||||
const entries = Object.entries(ICON_PATHS).map(([name, path]) => ` ${/-/.test(name) ? `'${name}'` : name}: '${path}'`).join(',\n');
|
||||
const nextTwig = twig.replace(/\{% set paths = \{\n[\s\S]*?\n\} %\}/, `{% set paths = {\n${entries}\n} %}`);
|
||||
if (nextTwig === twig && !twig.includes(entries)) throw new Error('Die Symbolliste in icon.html.twig wurde nicht gefunden.');
|
||||
await writeFile(twigFile, nextTwig);
|
||||
|
||||
const validatorFile = new URL('../src/Content/DocumentValidator.php', import.meta.url);
|
||||
const validator = await readFile(validatorFile, 'utf8');
|
||||
const list = Object.keys(ICON_PATHS).map(name => `'${name}'`).join(', ');
|
||||
const nextValidator = validator.replace(/public const ICONS = \[[^\]]*\];/, `public const ICONS = [${list}];`);
|
||||
await writeFile(validatorFile, nextValidator);
|
||||
console.log(`${Object.keys(ICON_PATHS).length} Symbole geschrieben.`);
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Schneidet aus dem gelieferten Banner (kantarci-banner.jpeg, 901 × 458) die Bilder der
|
||||
* KANTARCI-Landingpage zu: das Logo, das Hausmotiv und ein quadratisches Seitensymbol.
|
||||
* Die Mediathek nimmt kein SVG, deshalb entsteht das Symbol als PNG.
|
||||
*
|
||||
* Aufruf: php scripts/build-kantarci-assets.php <banner.jpeg> <zielordner>
|
||||
*/
|
||||
|
||||
[$_, $banner, $ziel] = $argv + [null, null, null];
|
||||
if (!is_file((string) $banner) || null === $ziel) {
|
||||
fwrite(STDERR, "Aufruf: php scripts/build-kantarci-assets.php <banner.jpeg> <zielordner>\n");
|
||||
exit(1);
|
||||
}
|
||||
if (!is_dir($ziel)) {
|
||||
mkdir($ziel, 0777, true);
|
||||
}
|
||||
$quelle = imagecreatefromjpeg($banner);
|
||||
|
||||
$zuschnitt = static function (int $x, int $y, int $b, int $h, int $faktor = 1) use ($quelle) {
|
||||
$bild = imagecreatetruecolor($b * $faktor, $h * $faktor);
|
||||
imagecopyresampled($bild, $quelle, 0, 0, $x, $y, $b * $faktor, $h * $faktor, $b, $h);
|
||||
|
||||
return $bild;
|
||||
};
|
||||
|
||||
// Logo: Bildmarke und Schriftzug oben links, auf Weiß.
|
||||
imagepng($zuschnitt(22, 8, 396, 104), $ziel.'/logo-kantarci.png', 9);
|
||||
|
||||
// Hausmotiv: der Fotoanteil rechts, oberhalb des goldenen Bands.
|
||||
imagejpeg($zuschnitt(500, 0, 401, 334), $ziel.'/haus.jpg', 90);
|
||||
|
||||
// Seitensymbol: die Bildmarke mittig auf Weiß, 256 × 256.
|
||||
$symbol = imagecreatetruecolor(256, 256);
|
||||
imagefill($symbol, 0, 0, imagecolorallocate($symbol, 255, 255, 255));
|
||||
imagecopyresampled($symbol, $quelle, 28, 28, 26, 12, 200, 200, 80, 80);
|
||||
imagepng($symbol, $ziel.'/symbol-kantarci.png', 9);
|
||||
|
||||
echo "Bilder geschrieben nach $ziel\n";
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Bereitet die Bilder der Seite „Kantarci Markt – Neueröffnung“ für die Mediathek vor.
|
||||
*
|
||||
* Die Mediathek nimmt kein SVG. Das Seitensymbol und der runde Stempel „Frische • Qualität •
|
||||
* Vertrauen“ der Vorlage sind aber SVG (der Stempel mit Text auf einem Kreisbogen, das kann kein
|
||||
* Baustein). Chromium rastert beide zu PNG. Das Logo der Vorlage (1536 × 1024, 1,3 MB) wird auf
|
||||
* seinen sichtbaren Teil zugeschnitten und auf 600 px Breite verkleinert (doppelte Anzeigebreite),
|
||||
* angezeigt wird es mit höchstens 300 px.
|
||||
*
|
||||
* node scripts/build-kantarci-markt-assets.mjs [ordner]
|
||||
* → logo.png, symbol.png, stempel.png im Ordner (Standard var/kantarci-markt-assets)
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
const ordner = process.argv[2] || 'var/kantarci-markt-assets';
|
||||
const FORST = '#123b26', GOLD = '#a88332', GOLD_HELL = '#d2b978';
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const seite = await browser.newPage();
|
||||
|
||||
// Logo: sichtbaren Bereich über den Alphakanal bestimmen, zuschneiden, verkleinern.
|
||||
const logo = (await readFile(`${ordner}/kantarci-markt-logo.png`)).toString('base64');
|
||||
const logoPng = await seite.evaluate(async (daten) => {
|
||||
const bild = new Image();
|
||||
bild.src = `data:image/png;base64,${daten}`;
|
||||
await bild.decode();
|
||||
const quelle = new OffscreenCanvas(bild.width, bild.height);
|
||||
const q = quelle.getContext('2d');
|
||||
q.drawImage(bild, 0, 0);
|
||||
const { data } = q.getImageData(0, 0, bild.width, bild.height);
|
||||
let [x0, y0, x1, y1] = [bild.width, bild.height, 0, 0];
|
||||
for (let y = 0; y < bild.height; y++) {
|
||||
for (let x = 0; x < bild.width; x++) {
|
||||
// Der Schein um die Waage läuft sehr flach aus; erst ab etwa 8 % Deckkraft zählt er.
|
||||
if (data[(y * bild.width + x) * 4 + 3] > 20) {
|
||||
x0 = Math.min(x0, x); y0 = Math.min(y0, y); x1 = Math.max(x1, x); y1 = Math.max(y1, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
const rand = 8;
|
||||
x0 = Math.max(0, x0 - rand); y0 = Math.max(0, y0 - rand);
|
||||
x1 = Math.min(bild.width - 1, x1 + rand); y1 = Math.min(bild.height - 1, y1 + rand);
|
||||
const breite = x1 - x0 + 1, hoehe = y1 - y0 + 1;
|
||||
const ziel = Math.min(600, breite);
|
||||
const leinwand = new OffscreenCanvas(ziel, Math.round(hoehe * ziel / breite));
|
||||
const z = leinwand.getContext('2d');
|
||||
z.imageSmoothingQuality = 'high';
|
||||
z.drawImage(bild, x0, y0, breite, hoehe, 0, 0, leinwand.width, leinwand.height);
|
||||
const blob = await leinwand.convertToBlob({ type: 'image/png' });
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
let binaer = '';
|
||||
for (const b of bytes) binaer += String.fromCharCode(b);
|
||||
return { png: btoa(binaer), breite: leinwand.width, hoehe: leinwand.height, ausschnitt: [x0, y0, breite, hoehe] };
|
||||
}, logo);
|
||||
await writeFile(`${ordner}/logo.png`, Buffer.from(logoPng.png, 'base64'));
|
||||
console.log(`logo.png ${logoPng.breite} × ${logoPng.hoehe} (Ausschnitt ${logoPng.ausschnitt.join(', ')})`);
|
||||
|
||||
const rastern = async (svg, groesse, datei) => {
|
||||
await seite.setViewportSize({ width: groesse, height: groesse });
|
||||
await seite.setContent(`<!doctype html><style>html,body{margin:0;background:transparent}svg{display:block}</style>${svg}`);
|
||||
await seite.screenshot({ path: `${ordner}/${datei}`, omitBackground: true });
|
||||
console.log(`${datei} ${groesse} × ${groesse}`);
|
||||
};
|
||||
|
||||
// Seitensymbol: das K mit Blatt aus dem Favicon der Vorlage.
|
||||
await rastern(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="256" height="256">
|
||||
<rect width="64" height="64" rx="14" fill="#062f24"/>
|
||||
<path d="M19 43V18h7v10l10-10h9L33 30l13 13h-9L26 32v11z" fill="${GOLD}"/>
|
||||
<path d="M41 10c-7 2-10 6-9 13 6-1 10-5 9-13Z" fill="${GOLD}"/></svg>`, 256, 'symbol.png');
|
||||
|
||||
// Stempel: goldene Scheibe, Umschrift auf dem Kreis, Blatt in der Mitte. Doppelte Größe für
|
||||
// scharfe Kanten auf hochauflösenden Bildschirmen (angezeigt mit 122 px).
|
||||
await rastern(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 122 122" width="244" height="244">
|
||||
<circle cx="61" cy="61" r="61" fill="${GOLD_HELL}"/>
|
||||
<g transform="translate(10 10) scale(1.1333)">
|
||||
<defs><path id="kreis" d="M45,45 m-31,0 a31,31 0 1,1 62,0 a31,31 0 1,1 -62,0"/></defs>
|
||||
<text font-family="Arial, sans-serif" font-weight="700" font-size="8" letter-spacing="1.4" fill="${FORST}"><textPath href="#kreis">FRISCHE • QUALITÄT • VERTRAUEN • </textPath></text>
|
||||
<path d="M45 52c-2-16 6-25 22-26-1 16-8 24-22 26Zm0 0c-8-11-17-13-27-7 7 12 16 14 27 7Z" fill="${FORST}"/>
|
||||
</g></svg>`, 244, 'stempel.png');
|
||||
|
||||
await browser.close();
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Zeichnet die Bildkarten der KANTARCI-Mietanfrage und rastert sie zu PNG.
|
||||
*
|
||||
* Die Formularbausteine zeigen eine Auswahl als Bildkarten, sobald eine Option ein Bild aus der
|
||||
* Mediathek hat - und die Mediathek nimmt kein SVG. Deshalb entstehen die Symbole hier als SVG
|
||||
* und werden mit Chromium auf 800 × 600 (4:3 wie die Karte) gerastert. Stil der Landingpage:
|
||||
* dünne Linie in Navy, runde Enden, ein goldener Akzent, warme helle Fläche dahinter.
|
||||
*
|
||||
* node scripts/build-kantarci-options.mjs → var/kantarci-immobilien-assets/options/*.png und *.svg
|
||||
*/
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
|
||||
const ziel = process.argv[2] || 'var/kantarci-immobilien-assets/options';
|
||||
const NAVY = '#142535', GOLD = '#b49a66', HELL = '#f5f3ee', PAPIER = '#ffffff';
|
||||
|
||||
const bild = (inhalt) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600">
|
||||
<rect width="800" height="600" fill="${HELL}"/>
|
||||
<g fill="none" stroke="${NAVY}" stroke-width="14" stroke-linecap="round" stroke-linejoin="round">${inhalt}</g>
|
||||
</svg>`;
|
||||
|
||||
/** Eine Person: Kopf und Schultern. */
|
||||
const person = (x, y, s = 1, farbe = NAVY) => `
|
||||
<circle cx="${x}" cy="${y - 120 * s}" r="${46 * s}" stroke="${farbe}"/>
|
||||
<path d="M${x - 88 * s} ${y + 70 * s}v-${30 * s}a${88 * s} ${88 * s} 0 0 1 ${176 * s} 0v${30 * s}" stroke="${farbe}"/>`;
|
||||
|
||||
/** Ein Grundriss: Außenwand, dazu Innenwände; der jeweils letzte Raum ist golden hinterlegt. */
|
||||
const grundriss = (waende, akzent) => `
|
||||
${akzent ? `<path d="${akzent}" fill="${GOLD}" fill-opacity=".22" stroke="none"/>` : ''}
|
||||
<rect x="170" y="130" width="460" height="340" rx="8"/>
|
||||
${waende}`;
|
||||
|
||||
const bilder = {
|
||||
// Wer zieht ein?
|
||||
'haushalt-allein': person(400, 330, 1.3),
|
||||
'haushalt-zu-zweit': person(275, 330, 1.1) + person(525, 330, 1.1, GOLD),
|
||||
'haushalt-familie': person(250, 320, 1.1) + person(550, 320, 1.1) + person(400, 400, .7, GOLD),
|
||||
'haushalt-wg': person(200, 340, .9) + person(400, 340, .9, GOLD) + person(600, 340, .9),
|
||||
|
||||
// Wie viele Zimmer?
|
||||
'zimmer-1': grundriss('<path d="M340 470v-50" />', 'M177 137h446v326H177z'),
|
||||
'zimmer-2': grundriss('<path d="M400 130v230M400 420v50"/>', 'M407 137h216v326H407z'),
|
||||
'zimmer-3': grundriss('<path d="M360 130v230M360 420v50M360 300h270"/>', 'M367 307h256v156H367z'),
|
||||
'zimmer-4': grundriss('<path d="M400 130v340M170 300h170M460 300h170"/>', 'M407 307h216v156H407z')
|
||||
+ `<path d="M690 110v80M650 150h80" stroke="${GOLD}"/>`,
|
||||
|
||||
// Was ist Ihnen wichtig?
|
||||
'ausstattung-balkon': `
|
||||
<path d="M250 110h300v230H250z"/><path d="M400 110v230"/>
|
||||
<path d="M170 340h460M170 340v150M630 340v150M170 490h460" stroke="${GOLD}"/>
|
||||
<path d="M245 340v150M320 340v150M400 340v150M480 340v150M555 340v150" stroke="${GOLD}" stroke-width="10"/>`,
|
||||
'ausstattung-aufzug': `
|
||||
<rect x="230" y="100" width="340" height="400" rx="12"/><path d="M400 100v400"/>
|
||||
<path d="M620 210l40-50 40 50z" fill="${GOLD}" stroke="${GOLD}"/><path d="M620 300l40 50 40-50z" fill="${GOLD}" stroke="${GOLD}"/>`,
|
||||
'ausstattung-stellplatz': `
|
||||
<rect x="230" y="110" width="340" height="380" rx="40"/>
|
||||
<path d="M340 420V180h80a60 60 0 0 1 0 120h-80" stroke="${GOLD}" stroke-width="22"/>`,
|
||||
'ausstattung-kueche': `
|
||||
<rect x="180" y="200" width="440" height="290" rx="10"/><path d="M180 270h440"/>
|
||||
<circle cx="300" cy="235" r="14" fill="${NAVY}" stroke="none"/><circle cx="360" cy="235" r="14" fill="${NAVY}" stroke="none"/>
|
||||
<rect x="250" y="320" width="300" height="120" rx="8" stroke="${GOLD}"/>
|
||||
<path d="M300 140c0-30 30-30 30-60M400 140c0-30 30-30 30-60M500 140c0-30 30-30 30-60" stroke="${GOLD}" stroke-width="10"/>`,
|
||||
'ausstattung-barrierefrei': `
|
||||
<circle cx="380" cy="130" r="36" stroke="${GOLD}"/>
|
||||
<path d="M380 180v130h120l50 110" stroke="${GOLD}"/><path d="M380 240h100"/>
|
||||
<path d="M330 260a120 120 0 1 0 170 150"/>`,
|
||||
'ausstattung-haustier': `
|
||||
<path d="M400 330c-70 0-130 70-130 120 0 40 40 50 70 40s50-10 60-10 30 0 60 10 70 0 70-40c0-50-60-120-130-120z" fill="${GOLD}" fill-opacity=".25" stroke="${GOLD}"/>
|
||||
<ellipse cx="260" cy="270" rx="42" ry="55"/><ellipse cx="350" cy="190" rx="42" ry="58"/>
|
||||
<ellipse cx="450" cy="190" rx="42" ry="58"/><ellipse cx="540" cy="270" rx="42" ry="55"/>`,
|
||||
};
|
||||
|
||||
await mkdir(ziel, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 800, height: 600 }, deviceScaleFactor: 1 });
|
||||
for (const [name, inhalt] of Object.entries(bilder)) {
|
||||
const svg = bild(inhalt);
|
||||
await writeFile(`${ziel}/${name}.svg`, svg);
|
||||
await page.setContent(`<!doctype html><body style="margin:0;background:${PAPIER}">${svg}</body>`);
|
||||
await page.locator('svg').screenshot({ path: `${ziel}/${name}.png`, omitBackground: false });
|
||||
console.log('gezeichnet:', name);
|
||||
}
|
||||
await browser.close();
|
||||
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user