Compare commits

..
9 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5.5 3e6cd680f6 Release 1.43.1
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:56:56 +01:00
kantarciandClaude Opus 5.5 84cdf6a261 Give both consent answers the same look
"Alle akzeptieren" was filled in the button colour while "Nur notwendige"
was an outlined button. A privacy scan flagged that as nudging. Both now
share fill, text colour and hover; only "Auswahl speichern" in the
per-group disclosure stays outlined. ADR 131.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:56:56 +01:00
kantarciandClaude Fable 5.2 86da83f754 Note the live measurement of 1.43.0 in the status
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 13:10:17 +01:00
kantarciandClaude Fable 5.2 8b6b53fe0f Note the 1.43.0 release in the status
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 13:04:21 +01:00
kantarciandClaude Fable 5.2 732b86897c Note the 1.43.0 release in the status
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 13:01:24 +01:00
kantarciandClaude Fable 5.2 cecb0771db Release 1.43.0
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 13:01:24 +01:00
kantarciandClaude Fable 5.2 b08862b139 Back up unsaved editor work locally, structure the settings, fix archive accessibility
- The editor keeps unsaved changes in the browser and offers them
  after a crash, a closed tab or an expired session. No timed save on
  the server: every save is a full revision.
- The save status stays visible in narrow windows.
- Settings get a sticky section bar, two folded groups and a save bar
  that stays in view.
- The admin post list fetches terms and images once, not per post.
- Archive post titles are second-level headings, the archive head
  keeps full text contrast, the skip link sits above a sticky header,
  a linked image without alternative text names its link.
- The editor check starts through the dev router like the others.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 13:01:23 +01:00
kantarciandClaude Fable 5.2 5e46c98825 Note the live measurement of 1.42.0 in the status
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 12:44:09 +01:00
kantarciandClaude Fable 5.2 4c663d8087 Note the 1.42.0 release in the status
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 12:36:01 +01:00
20 changed files with 242 additions and 34 deletions

No files matched your search

+1 -1
View File
@@ -66,7 +66,7 @@ Unter **Formulare → Zustellung** gibt es neben dem Empfänger ein Feld **Absen
## 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 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“ steht gleich groß und in derselben Farbe daneben (ADR 131). 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.
+1 -1
View File
@@ -1 +1 @@
1.42.0
1.43.1
+17 -1
View File
@@ -2,6 +2,22 @@
Stand: 10.11.2026. Arbeitstitel KantSites; Produktname offen.
## ADR 131 – Einwilligung: beide Antworten sehen gleich aus (11.11.2026)
**Anlass:** Ein Datenschutz-Scan (SUASIO) von huefner-gartenbau.de meldete die Einwilligung als „nicht rechtskonform“ wegen Nudging. „Alle akzeptieren“ war in der Knopffarbe gefüllt, „Nur notwendige“ ein Rahmenknopf – gleich groß, aber die Zustimmung farbig hervorgehoben. Aufsichtsbehörden werten eine solche Hervorhebung zunehmend als Lenkung.
**Entscheidung:** „Nur notwendige“ bekommt dieselbe Füllung, Schrift und Hover-Wirkung wie „Alle akzeptieren“ (`.consent-decline` hat keine eigenen Regeln mehr). Die einstellbare Farbe heißt jetzt „Farbe der Knöpfe“ und gilt für beide. „Auswahl speichern“ in der aufgeklappten Einzelwahl bleibt ein Rahmenknopf, weil es dort keine konkurrierende Antwort gibt. Das ersetzt den Absatz „Was gleich bleibt“ aus ADR 097.
**Folgen:** Alle Installationen ändern ihr Banner mit dem Update, ohne Einstellungsänderung. Der Kontrast der Knopfschrift gilt nun für beide Knöpfe; wer eine helle Knopffarbe gewählt hat, muss „Schrift auf dem Knopf“ passend setzen. Ob ein Scanner oder eine Behörde das Banner damit als rechtskonform ansieht, ist nicht garantiert.
## ADR 130 – Editor: lokale Sicherung statt Autosave auf dem Server (10.11.2026)
**Kontext:** Der Entwicklungsplan nennt „Autosave mit sichtbarem Speicherstatus“. Gespeichert wurde nur per Knopf oder Strg+S; nach Absturz, geschlossenem Tab oder abgelaufener Sitzung war die Arbeit weg, und „Als JSON sichern“ hatte kein Gegenstück zum Einlesen.
**Entscheidung:** Ungespeicherte Änderungen liegen zusätzlich im `localStorage` des Browsers (Schlüssel `kant-editor:<API-Adresse>`, je Seite und Bereich), geschrieben 0,6 s nach der letzten Änderung und beim Verlassen. Beim Öffnen bietet der Editor eine abweichende Sicherung zum Wiederherstellen an – als ein Schritt im Verlauf – oder zum Verwerfen. Nach dem Speichern verschwindet sie. Kein Speichern auf dem Server im Takt.
**Folgen:** Jedes Speichern legt eine vollständige Fassung in `revision` an, und die wird nie gekürzt (ADR 009 ff.); ein Autosave alle paar Sekunden hätte die Fassungsliste entwertet und die Datenbank wachsen lassen. Das wäre erst mit zusammengefassten Entwurfsfassungen vertretbar – eine Schemaänderung, die hier nicht ansteht. Die Sicherung gilt nur für dieses Gerät und diesen Browser und ersetzt kein Speichern. Ist der Serverstand inzwischen neuer, sagt das Angebot das dazu; das Speichern danach überschreibt ihn nicht still, sondern legt eine neue Fassung über die fremde. Ist der Speicher des Browsers voll oder gesperrt, arbeitet der Editor ohne Sicherung weiter.
## ADR 129 – Bilder: Stufen 720 und 1280, Abbild für den Webserver (10.11.2026)
**Kontext:** Die Komplettanalyse maß auf kant-it.de rund 250 ms bis zum ersten Byte eines Bildes gegen rund 100 ms für eine statische Datei, weil jedes Bild durch die Anwendung lief (ADR 009). Zwischen den Stufen 480 und 960 lag außerdem genau der Bedarf eines Telefons (412 px × 1,75 = 721 px); Becker kam damit mobil auf einen LCP von 2,6 s.
@@ -443,7 +459,7 @@ Umut wollte für die DEEG-Website „kein Kontaktformular weiteres, sondern so e
**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.
**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. *(Ab 1.43.1 abgelöst durch ADR 131: beide Knöpfe sehen gleich aus.)*
**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.
+42 -1
View File
@@ -1,5 +1,42 @@
# Projektstatus
## Fassung 1.43.1: Einwilligung – beide Antworten gleich gestaltet (11.11.2026)
**Anlass:** SUASIO-Scan von huefner-gartenbau.de: Einwilligung „nicht rechtskonform“ wegen Nudging (grün gefülltes „Alle akzeptieren“ neben Rahmenknopf „Nur notwendige“). ADR 131.
**Erledigt:** `site.css`: „Nur notwendige“ hat dieselbe Füllung, Schrift und Hover-Wirkung wie „Alle akzeptieren“; nur „Auswahl speichern“ bleibt Rahmenknopf. Einstellungen: Feld heißt „Farbe der Knöpfe“, Hilfetext angepasst; README und ADR 097 nachgezogen.
**Geprüft:** Live-Seite: kein Cookie beim Laden, `kant_consent` erst nach der Wahl; Formular mit Labels und Datenschutz-Checkbox. Lokal (Hüfner, 8102): beide Knöpfe `rgb(6,127,48)`, weiße Schrift, gleich groß. PHPUnit 701 grün (PHP 8.4), `test:editor` grün (inkl. Banner und axe), `test:buttons` grün.
**Bekannte Fehler / offen:** `test:responsive-navigation` scheitert im Editor-Strukturbaum (`qa-all` nicht sichtbar, Zeile 54), vor dem Banner-Teil; auf 1.43.0 nicht nachgestellt (Release-Ordner ohne `vendor`). `test:browser` gegen Hüfner scheitert an der HTML-Obergrenze (153 529 > 150 000 Bytes), bekannt knapp. Ob SUASIO den Punkt nach dem Update grün meldet, ist erst nach einem neuen Scan sicher. In der Datenschutzerklärung von Hüfner `kant_consent` mit Name, Zweck und Dauer nennen (macht Umut).
**Nächster Schritt:** Release 1.43.1 veröffentlichen, Hüfner aktualisieren, neu scannen.
## Fassung 1.43.0: Oberfläche – lokale Sicherung im Editor, Einstellungen gegliedert, Archive barrierefrei (10.11.2026)
**Anlass:** Umut: „Ja, weiter“ – das vierte Paket aus der Komplettanalyse. ADR 130.
**Erledigt (keine Migration, kein Schemawechsel):**
- E3: Der Editor sichert ungespeicherte Änderungen im Browser und bietet sie nach Absturz, geschlossenem Tab oder abgelaufener Sitzung zum Wiederherstellen an. Bewusst kein Autosave auf dem Server (ADR 130).
- E6: Der Speicherstatus bleibt unter 1100 px Fensterbreite sichtbar (Kurzform „● Ungespeichert“, „Speichert …“, „● Konflikt“).
- Einstellungen: Sprungleiste zu den acht Abschnitten, die beim Scrollen stehen bleibt; „Soziale Profile“ und „Öffnungszeiten“ zugeklappt mit Zusammenfassung, solange nichts eingetragen ist; die Speicherleiste steht am unteren Rand. Ein ungültiges Feld in einem zugeklappten Bereich klappt ihn beim Absenden auf.
- Beitragsliste der Verwaltung: Kategorien, Schlagwörter und Bilder in je einer Abfrage statt je Beitrag.
- B1: Im Archiv ist ein Beitragstitel eine Überschrift zweiter Ebene (in Seiten weiter dritter).
- B2: Der Text im Archivkopf erbt die volle Textfarbe (vorher 4,48:1 auf dem getönten Grund).
- B3: Der Sprunglink liegt über einem klebenden Kopf.
- B4: Ein verlinktes Bild ohne Alternativtext gibt dem Link einen Namen.
- `test:editor` startet wie alle anderen Prüfungen über `bin/dev-router.php`.
**Geprüft:** PHPUnit 701 grün (PHP 8.4, zwei Tests erweitert), `npm test` 41, php-cs-fixer, Twig-Lint, `test:editor` grün. Im Browser (Playwright, Pilotkopie): Titel ändern, 1 s warten – Sicherung liegt im Browser; neu laden – Angebot mit Uhrzeit; Wiederherstellen – Änderung im Baum, „Rückgängig“ aktiv; Speichern – auf dem Server und Sicherung weg; Verwerfen – Sicherung weg. Status bei 1000 px Breite sichtbar. Einstellungen: Sprung zu „Spamschutz“ landet unter der Leiste, Speicherleiste am unteren Rand sichtbar, 390 px ohne Querscrollen, ungültige Adresse klappt den Bereich auf. Aufnahmen `reports/lokale-sicherung-1.43.png`, `reports/einstellungen-1.43.png`. Nicht geprüft: PHP 8.2, `test:browser`, Safari, Lighthouse für `/beitraege` nach der Änderung (live erst nach dem Update messbar), die Abfragezahl der Beitragsliste als Zahl (im Produktionsmodus fehlt die Kopfzeile; Antwort lokal 119 ms).
**Bekannte Fehler / offen:** Aus der Analyse offen: E7 Ziehen im Strukturbaum, E9 Design-Panel verliert Suche und Fokus, E10 Trägheit bei großen Seiten, Symbole als SVG (rund 120 Stellen), Farb- und Maß-Tokens, Tabellen auf dem Telefon, B5–B7, P3/P4/P8/P9, F8.
**Release:** Tag `v1.43.0` auf `main`, Releaseeintrag mit Zip (3708 Dateien, 6,1 MB), `.sha256` und `install.php`, gebaut aus sauberem Checkout des Tags (ohne ImmoScout-Plugin); anonymer Download stimmt mit der Prüfsumme überein (5a7a83a1…), `releases/latest` meldet v1.43.0.
**Live nachgemessen (kant-it.de auf 1.43.0, 10.11.2026):** Lighthouse 13.4.1 (`audit.mjs`, je drei Läufe): `/`, `/projekte` und `/beitraege` mobil und Desktop überall 100 in allen fünf Bereichen – `/beitraege` vorher 95 bei der Barrierefreiheit (Überschriftenfolge und Kontrast im Archivkopf bestehen jetzt). Die Beitragsseite `/worga-baustellen-app-handwerk` bleibt bei 96: der Knopf „Zu worga.net“ hat weiße Schrift auf der Akzentfarbe #e63946 bei 14 px, Kontrast 4,16:1 – Inhalt und Design der Seite, kein Kernfehler. Im Archiv zehn Beitragstitel als `h2`, keiner als `h3`. „Bildgrößen ergänzen“ ist bei 4 von 18 Bildern der Startseite.
**Nächster Schritt:** Die übrigen Installationen in der Zentrale auf 1.43.0 bringen. Für kant-it.de entscheiden, ob der rote Knopf auf den Beitragsseiten eine dunklere Fläche (#c1121f, die Linkfarbe der Website, 5,9:1) oder größere fette Schrift bekommt. Früher notiert: live `/beitraege` und eine Beitragsseite mit Lighthouse nachmessen (Barrierefreiheit vorher 95 und 96).
## Fassung 1.42.0: Geschwindigkeit – kleinere Bilder fürs Telefon, Bilder ohne PHP (10.11.2026)
**Anlass:** Umut: „Ja, 1.42 angehen“ – das dritte Paket aus der Komplettanalyse. ADR 129.
@@ -22,7 +59,11 @@ Das ist ein lokaler Vergleich mit dem PHP-Entwicklungsserver; der Gewinn kommt h
**Bekannte Fehler / offen:** PHPs eingebauter Server ohne Router antwortet mit 404 auf ein Bild, zu dessen Kennung ein Ordner im Abbild liegt (lokal immer mit `bin/dev-router.php` starten). Aus der Analyse offen: P3 OPcache-Anzeige, P4 `no-store`, P5-Rest (Brotkrumen), P8 Layoutsprünge im Schrittformular und beim Video, P9 Stylesheet teilen; dazu Oberfläche und Barrierefreiheit der Archive.
**Nächster Schritt:** Zweig `tempo-1.42` nach `main`, Tag `v1.42.0`, Releaseeintrag – nach Umuts Freigabe. Nach dem Update auf einer Live-Seite prüfen: ein Bild zweimal abrufen, der zweite Abruf muss ohne `Set-Cookie`/PHP-Kopfzeilen und spürbar schneller kommen; unter Aufgaben läuft „Bildgrößen ergänzen“.
**Release:** Tag `v1.42.0` auf `main`, Releaseeintrag mit Zip (3708 Dateien, 6,1 MB), `.sha256` und `install.php`, gebaut aus sauberem Checkout des Tags (ohne ImmoScout-Plugin); anonymer Download stimmt mit der Prüfsumme überein (8fa187f9…), `releases/latest` meldet v1.42.0. Die Git-Anmeldung war wieder abgelaufen, Umut hat selbst gepusht.
**Live nachgemessen (kant-it.de, 10.11.2026, nach Umuts Update von 1.37.0 auf 1.42.0):** Die Regel greift beim Hoster. Erster Abruf eines Bildes über PHP (268 ms, mit CSP-Kopf der Anwendung), ab dem zweiten vom Webserver (ETag von Apache, 111–132 ms). Alle 59 Bildadressen der Startseite kommen danach statisch, im Mittel nach 112 ms statt vorher rund 250 ms – so schnell wie eine Skriptdatei. Eine unbekannte Breite antwortet weiter mit 404. „Bildgrößen ergänzen“ läuft: zum Messzeitpunkt trugen 2 von 18 Bildern der Startseite schon die Stufe 720. Lighthouse 13.4.1 (`audit.mjs`, simuliert, je drei Läufe) gegen die Messung vom Vormittag auf 1.37.0: LCP Desktop 642 → 425 ms (`/`), 534 → 398 ms (`/projekte`), 612 → 420 ms (Beitrag), 585 → 436 ms (`/beitraege`); mobil 1668 → 1690 ms (`/`), 1488 → 1499, 1711 → 1579, 1779 → 1697 ms. Übertragene Daten der Startseite mobil 1020 → 444 KB. Leistung überall 100 (Startseite Desktop vorher 99). Barrierefreiheit unverändert 95 auf `/beitraege` und 96 auf dem Beitrag (B1, B2 offen). Der Vergleich liegt Stunden und fünf Fassungen auseinander; mobil ist der LCP im Rahmen der Schwankung gleich.
**Nächster Schritt:** Die übrigen Installationen in der Zentrale auf 1.42.0 bringen. Dort jeweils prüfen: ein Bild zweimal abrufen, der zweite Abruf muss ohne `Set-Cookie`/PHP-Kopfzeilen und spürbar schneller kommen; unter Aufgaben läuft „Bildgrößen ergänzen“.
## Fassung 1.41.0: Stabilität – Altbestand lesbar, Konflikte, Sicherungen, Rückfragen (10.11.2026)
+16
View File
@@ -1165,3 +1165,19 @@ details.form-card > summary.form-card-summary::before, details.form-card[open] >
.confirm-dialog .dialog-actions .primary, .confirm-dialog .dialog-actions .secondary { margin: 0 }
.primary.danger { background: var(--danger); border-color: var(--danger) }
.primary.danger:hover:not(:disabled) { background: #6f251b; border-color: #6f251b }
/* ---- Lange Formulare: Sprungleiste, Klappbereiche, stehende Speicherleiste (Einstellungen) ---- */
.jump-nav { position: sticky; top: 0; z-index: 6; display: flex; flex-wrap: wrap; gap: 6px; margin: 0 -4px 6px; padding: 10px 4px; background: color-mix(in srgb, var(--bg, #f3f5f1) 92%, transparent); backdrop-filter: blur(6px) }
.jump-nav a { display: inline-flex; align-items: center; min-height: 32px; padding: 4px 12px; border: 1px solid var(--line); border-radius: 999px; background: var(--paper); color: var(--ink); font-size: 12.5px; font-weight: 600; text-decoration: none }
.jump-nav a:hover { border-color: var(--accent) }
.stack-section[id], .section-head h2[id] { scroll-margin-top: 64px }
.field-fold > summary { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 12px 0 8px; font-weight: 700; cursor: pointer; list-style: none }
.field-fold > summary::-webkit-details-marker { display: none }
.field-fold > summary::before { content: ''; flex: none; width: 7px; height: 7px; margin-right: 2px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: translateY(-2px) rotate(-45deg) }
.field-fold[open] > summary::before { transform: translateY(-3px) rotate(45deg) }
.field-fold > summary .muted { margin-left: auto; font-size: 12px; font-weight: 500 }
.field-fold:not([open]) { padding-bottom: 4px }
.form-save-bar { position: sticky; bottom: 0; z-index: 5; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 22px -22px -24px; padding: 14px 22px; border-top: 1px solid var(--line); border-radius: 0 0 12px 12px; background: color-mix(in srgb, var(--paper) 94%, transparent); backdrop-filter: blur(6px) }
.card-body .form-save-bar .primary { margin: 0 }
.form-save-bar .muted { font-size: 12px }
@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth } }
@media (max-width: 700px) { .jump-nav { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none } .jump-nav a { flex: none } .form-save-bar .muted { display: none } }
+4
View File
@@ -48,6 +48,10 @@
};
window.kantConfirm = ask;
// Ein ungültiges Feld in einem zugeklappten Bereich kann der Browser nicht anspringen - das
// Absenden bliebe ohne jede Meldung stehen. Der Bereich klappt deshalb auf.
document.addEventListener('invalid', event => { event.target.closest?.('details:not([open])')?.setAttribute('open', ''); }, true);
document.addEventListener('submit', event => {
const form = event.target;
if (!(form instanceof HTMLFormElement) || !form.dataset.confirmTitle || form.dataset.confirmed) return;
+2 -1
View File
@@ -38,6 +38,7 @@ html:has(>.editor-body){overflow:hidden;height:100%}
.editor-header .secondary{background:transparent;color:#fff;border:1px solid #ffffff4d;font-size:12px;padding:0 13px;min-height:34px;white-space:nowrap}
.editor-header .secondary:hover:not(:disabled){background:#ffffff1a;border-color:#ffffff99}
.editor-header .primary{font-size:12px;padding:0 15px;min-height:34px;white-space:nowrap}
.editor-message.editor-backup{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 16px;background:#eef2ea;color:#1b2a24;border-bottom-color:#cfd8cc}.editor-backup-actions{display:flex;gap:8px;align-items:center}.editor-backup .secondary,.editor-backup .text-button{margin:0;min-height:30px;padding:4px 12px;font-size:12px}
.editor-message{border-radius:0;background:#fff0e9;color:#873e24;border-bottom:1px solid #e6c3b2;flex-shrink:0;padding:9px 16px;font-size:12px;line-height:1.6}
.recovery-bar{display:flex;gap:12px;align-items:center;padding:10px 16px;border-bottom:1px solid var(--line);background:#fff;font-size:12px;flex-wrap:wrap}
.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:25px}
@@ -277,7 +278,7 @@ html:has(>.editor-body){overflow:hidden;height:100%}
@media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}}
/* ---- narrow screens ---------------------------------------------------------------------- */
@media(max-width:1100px){.editor-layout{grid-template-columns:290px minmax(0,1fr)}.editor-page-name{width:150px}.save-actions>span{display:none}.viewport-controls button span{display:none}.viewport-controls button{padding:0 9px}}
@media(max-width:1100px){.editor-layout{grid-template-columns:290px minmax(0,1fr)}.editor-page-name{width:150px}.save-actions>span{font-size:0;max-width:none;white-space:nowrap}.save-actions>span::before{font-size:11px;content:attr(data-short)}.save-actions>span[data-state=dirty]::before{content:'● Ungespeichert';color:#ffcf8a}.save-actions>span[data-state=saving]::before{content:'Speichert …'}.save-actions>span[data-state=conflict]::before{content:'● Konflikt';color:#ffb0a0}.save-actions>span[data-state=saved]::before{content:'Gespeichert'}.save-actions>span[data-state=loading]::before{content:'Lädt …'}.viewport-controls button span{display:none}.viewport-controls button{padding:0 9px}}
@media(max-width:850px){
.editor-body{overflow:auto}#editor{height:auto;min-height:100dvh}
.editor-header{flex-wrap:wrap;height:auto;padding:8px 10px;gap:8px}.editor-page-name{flex:1;width:auto}.viewport-controls{order:6;margin:0}.save-actions{margin-left:auto}
+54
View File
@@ -88,6 +88,51 @@ function playPreview() {
$('#preview-motion').addEventListener('click', () => { previewPlayback = !previewPlayback; playPreview(); });
// ---- Lokale Sicherung --------------------------------------------------------------------------
// Ungespeicherte Änderungen liegen zusätzlich im Browser dieses Geräts. Stürzt der Browser ab,
// wird der Tab geschlossen oder läuft die Sitzung ab, bietet der Editor sie beim nächsten Öffnen
// zum Wiederherstellen an. Bewusst kein Speichern auf dem Server im Takt: Jedes Speichern legt
// dort eine vollständige Fassung an, und die Liste der Fassungen soll zeigen, was jemand
// gespeichert hat - nicht jeden Tastendruck.
const backupKey = `kant-editor:${root.dataset.api}`;
let backupTimer = null, backupOffer = null;
function readBackup() {
try {
const entry = JSON.parse(localStorage.getItem(backupKey) || 'null');
return entry && Array.isArray(entry.document?.blocks) ? entry : null;
} catch { return null; }
}
function writeBackup() {
// Solange ein Angebot offen ist, bleibt die Sicherung unangetastet: Der eben geladene Stand
// ist unverändert und würde sie sonst löschen, bevor jemand entschieden hat.
if (!history || backupOffer) return;
try {
if (history.dirty) localStorage.setItem(backupKey, JSON.stringify({ at: Date.now(), revisionId, document: history.document }));
else localStorage.removeItem(backupKey);
} catch { /* Speicher voll oder gesperrt: dann ohne Sicherung, der Editor arbeitet weiter. */ }
}
function scheduleBackup() { clearTimeout(backupTimer); backupTimer = setTimeout(writeBackup, 600); }
function closeBackupOffer(remove) {
backupOffer = null; $('#local-backup').hidden = true;
if (remove) { try { localStorage.removeItem(backupKey); } catch { /* nichts zu tun */ } }
}
function offerBackup() {
const entry = readBackup();
if (!entry || JSON.stringify(entry.document) === JSON.stringify(history.document)) { closeBackupOffer(Boolean(entry)); return; }
backupOffer = entry;
const when = new Date(entry.at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
$('#local-backup-text').textContent = `Auf diesem Gerät liegen ungespeicherte Änderungen vom ${when} Uhr.`
+ (entry.revisionId && entry.revisionId !== revisionId ? ' Seitdem wurde die Seite auf dem Server gespeichert – beim Wiederherstellen ersetzt dein Stand den neueren, sobald du speicherst.' : '');
$('#local-backup').hidden = false;
}
function restoreBackup() {
const entry = backupOffer; if (!entry || !history || busy) return;
const wanted = repairSchema(clone(entry.document));
// Als ein Schritt im Verlauf: „Rückgängig“ führt zum geladenen Stand zurück.
history.change(doc => { for (const key of Object.keys(doc)) delete doc[key]; Object.assign(doc, wanted); });
closeBackupOffer(false); selected = null; changed();
}
function notify(message = '', recover = false) {
$('#message').textContent = message;
$('#message').hidden = !message;
@@ -118,6 +163,10 @@ async function request(path, body, signal) {
function controls() {
const ready = Boolean(history);
$('#save-status').textContent = busy ? 'Wird gespeichert …' : conflict ? 'Speicherkonflikt' : !ready ? 'Wird geladen …' : history.dirty ? 'Ungespeicherte Änderungen' : editingArea ? 'Bereich gespeichert' : revisionId === publishedRevisionId ? 'Veröffentlicht' : 'Entwurf gespeichert';
// Der Zustand als Kennung: Auf schmalen Fenstern zeigt die Kopfzeile statt des ganzen Satzes
// nur ein kurzes Wort dazu (editor.css) - vorher verschwand die Anzeige dort ganz.
$('#save-status').dataset.state = busy ? 'saving' : conflict ? 'conflict' : !ready ? 'loading' : history.dirty ? 'dirty' : 'saved';
if (ready && !busy) scheduleBackup();
$('#save').disabled = !ready || busy || conflict || !history.dirty;
if ($('#publish')) $('#publish').disabled = !ready || busy || conflict || history.dirty || revisionId === publishedRevisionId;
$('#undo').disabled = !ready || busy || !history.past.length;
@@ -1451,6 +1500,7 @@ async function load() {
registerPlugins(data.plugins || [], data.presets || []); buildLibrary(); buildPluginPresets();
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify();
if (language) setView('translate');
offerBackup();
changed(); resizePreview();
} catch (error) { notify(error.message, true); }
finally { busy = false; controls(); }
@@ -1952,6 +2002,10 @@ function onKey(event) {
}
}
window.addEventListener('keydown', onKey);
$('#local-backup-restore').addEventListener('click', restoreBackup);
$('#local-backup-discard').addEventListener('click', () => closeBackupOffer(true));
// Beim Verlassen sofort schreiben, nicht erst nach der Wartezeit.
window.addEventListener('pagehide', () => { clearTimeout(backupTimer); writeBackup(); });
window.addEventListener('beforeunload', event => { if (history?.dirty || busy) { event.preventDefault(); event.returnValue = ''; } });
buildLibrary();
// The workspace is a fixed frame; a scroll position restored from an earlier visit would push
+11 -8
View File
@@ -12,7 +12,7 @@ p{hyphens:auto}
/* Ü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: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}
.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)}#archiv-kopf .block-text{color:inherit}.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:60;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. */
@@ -27,15 +27,15 @@ p{hyphens:auto}
.menu-level-2{flex-direction:column;align-items:flex-start;gap:2px}
/* 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-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 :is(h2,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:'';display:inline-block;width:1em;height:1em;background:currentColor;-webkit-mask:var(--kant-svg-pfeil) center/contain no-repeat;mask:var(--kant-svg-pfeil) center/contain no-repeat;vertical-align:-.12em;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: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-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 :is(h2,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}
.kant .post-card h3{margin:0;font-size:19px;font-weight:800;line-height:1.12;color:var(--ink);letter-spacing:-.2px}
.post-card h3 a{text-decoration:none}
.post-card h3 a:hover{text-decoration:underline}
.kant .post-card :is(h2,h3){margin:0;font-size:19px;font-weight:800;line-height:1.12;color:var(--ink);letter-spacing:-.2px}
.post-card :is(h2,h3) a{text-decoration:none}
.post-card :is(h2,h3) a:hover{text-decoration:underline}
.post-meta,.post-header{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:baseline;font-size:13px;margin:0}
.post-header{padding-block:22px 0}
.post-excerpt{margin:0;line-height:1.65}
@@ -199,9 +199,12 @@ p{hyphens:auto}
.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;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)}
/* „Alle akzeptieren“ und „Nur notwendige“ sehen gleich aus (ADR 131): eine farbig hervorgehobene
Zustimmung neben einem Rahmenknopf gilt als Lenkung. Nur „Auswahl speichern“ in der
aufgeklappten Einzelwahl bleibt ein Rahmenknopf. */
.consent-choice{background:transparent;color:inherit;border-color:color-mix(in srgb,currentColor 28%,transparent)}
.consent-button:hover{filter:brightness(.94)}
.consent-decline:hover,.consent-choice:hover{filter:none;background:color-mix(in srgb,currentColor 7%,transparent)}
.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. */
+1 -1
View File
@@ -14,7 +14,7 @@ await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve));
const port = probe.address().port;
await new Promise(resolve => probe.close(resolve));
const origin = `http://127.0.0.1:${port}`;
const server = spawn(php, ['-d', 'upload_max_filesize=8M', '-d', 'post_max_size=10M', '-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public'], {
const server = spawn(php, ['-d', 'upload_max_filesize=8M', '-d', 'post_max_size=10M', '-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], {
windowsHide: true,
env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild', SITE_ORIGIN: origin },
stdio: 'ignore',
+2 -1
View File
@@ -130,7 +130,8 @@ final class MediaLibrary
'SELECT * FROM media WHERE id IN ('.implode(',', array_fill(0, count($ids), '?')).')',
$ids,
) as $row) {
$found[$row['id']] = $this->present($row);
// Auch für find(): Wer die Bilder einer Liste vorab holt, erspart jede Einzelabfrage danach.
$found[$row['id']] = $this->gemerkt[$row['id']] = $this->present($row);
}
return $found;
+34
View File
@@ -313,6 +313,40 @@ final class PostRepository
}
/** Term names of one page, as the comma separated list the metadata form edits. */
/**
* Kategorien und Schlagwörter mehrerer Beiträge in einer Abfrage, als Text je Beitrag.
*
* Die Liste der Verwaltung fragte vorher für jeden Beitrag einzeln nach: zwanzig Beiträge,
* vierzig Abfragen.
*
* @param list<string> $pageIds
*
* @return array<string, array{category: string, tag: string}>
*/
public function namesForMany(array $pageIds): array
{
$names = [];
foreach ($this->termsForMany($pageIds) as $id => $terms) {
$names[$id] = [
'category' => implode(', ', array_column($terms['category'], 'name')),
'tag' => implode(', ', array_column($terms['tag'], 'name')),
];
}
return $names;
}
/**
* Holt die Bilder einer Liste in einer Abfrage, damit die Vorlage sie danach einzeln findet,
* ohne je Zeile die Datenbank zu fragen.
*
* @param list<string|null> $mediaIds
*/
public function preloadImages(array $mediaIds): void
{
$this->media->findMany(array_map('strval', array_filter($mediaIds)));
}
public function namesFor(string $pageId): array
{
$terms = $this->termsFor($pageId);
+3 -1
View File
@@ -49,11 +49,13 @@ final class EditorController extends AbstractController
$result = $this->pages->editorPage($type, $request->query->getInt('seite', 1), $request->query->getInt('limit', 20), $query, $status);
$pages = $result['items'];
$parentChoices = [];
$terms = 'post' === $type ? $this->posts->namesForMany(array_column($pages, 'id')) : [];
$this->posts->preloadImages([...array_column($pages, 'featured_media_id'), ...array_column($pages, 'og_media_id')]);
foreach ($pages as &$page) {
$page['document'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
$page['title'] = $page['document']['title'] ?? $page['slug'];
$page['depth'] = substr_count((string) $page['path'], '/');
$page['terms'] = 'post' === $page['type'] ? $this->posts->namesFor($page['id']) : ['category' => '', 'tag' => ''];
$page['terms'] = $terms[$page['id']] ?? ['category' => '', 'tag' => ''];
if ($page['parent_id'] && !isset($parentChoices[$page['parent_id']])) {
$parentChoices[$page['parent_id']] = $this->pages->choices(id: $page['parent_id'])[0] ?? null;
}
+1 -1
View File
@@ -960,7 +960,7 @@ final class PageController extends AbstractController
'blocks' => [
['id' => 'archiv-kopf', 'type' => 'section', 'layout' => ['tone' => 'mint', 'width' => 'normal'], 'children' => $kopf],
['id' => 'archiv', 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => [
['id' => 'archiv-liste', 'type' => 'postlist', 'props' => ['count' => PostRepository::PER_PAGE, 'category' => 'category' === $kind && null !== $term ? $term['slug'] : '', 'tag' => 'tag' === $kind && null !== $term ? $term['slug'] : '', 'layout' => 'karten', 'paginate' => true]],
['id' => 'archiv-liste', 'type' => 'postlist', 'props' => ['count' => PostRepository::PER_PAGE, 'category' => 'category' === $kind && null !== $term ? $term['slug'] : '', 'tag' => 'tag' === $kind && null !== $term ? $term['slug'] : '', 'layout' => 'karten', 'paginate' => true, 'headingLevel' => 2]],
]],
],
];
+1
View File
@@ -37,6 +37,7 @@
</div>
</header>
<div id="message" class="editor-message" role="alert" hidden></div>
<div id="local-backup" class="editor-message editor-backup" role="status" hidden><span id="local-backup-text"></span><span class="editor-backup-actions"><button type="button" class="secondary" id="local-backup-restore">Wiederherstellen</button><button type="button" class="text-button" id="local-backup-discard">Verwerfen</button></span></div>
<div id="recovery" class="recovery-bar" hidden><button id="download" class="secondary">Änderungen als JSON sichern</button><button id="reload" class="secondary">Serverstand laden</button><a href="{{ path('admin_login') }}" target="_blank" rel="noopener">Erneut anmelden ↗</a></div>
<main id="admin-main" class="editor-layout" tabindex="-1">
<aside class="sidebar" aria-label="Bearbeitung" data-view="elements">
+34 -14
View File
@@ -6,6 +6,19 @@
{% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script><script type="module" src="/assets/favicon-field.js?v={{ kant_version() }}"></script><script type="module" src="/assets/logo-field.js?v={{ kant_version() }}"></script>{% endblock %}
{% block content %}
{# Die Seite ist lang. Die Leiste bleibt beim Scrollen stehen und führt zu jedem Abschnitt. #}
<nav class="jump-nav" aria-label="Abschnitte dieser Seite">
<a href="#not-found">404-Seite</a>
<a href="#sprachen">Sprachen</a>
<a href="#unternehmen">Unternehmen</a>
{% if is_granted('ROLE_ADMIN') %}
<a href="#favicon-title">Symbol</a>
<a href="#nach-oben">Knopf „nach oben“</a>
<a href="#spamschutz">Spamschutz</a>
<a href="#einwilligung">Einwilligung</a>
{% endif %}
<a href="#weiterleitungen">Weiterleitungen</a>
</nav>
{% if missingLegal is not empty %}
{# Not a hint, a warning: in Germany both pages are required, and they have to be reachable from
@@ -99,7 +112,7 @@
{% endif %}
</div>
</section>
<section class="stack-section">
<section class="stack-section" id="unternehmen">
<div class="section-head"><h2>Unternehmensdaten</h2><span class="muted">Grundlage für Organization und LocalBusiness</span></div>
{% if is_granted('ROLE_ADMIN') %}
<form action="{{ path('admin_settings_save') }}" method="post" class="card card-body">
@@ -146,8 +159,9 @@
<label for="sameAs">Profile</label>
<textarea id="sameAs" name="sameAs" rows="3" placeholder="https://www.linkedin.com/company/…">{{ settings.sameAs|join('\n') }}</textarea>
<p class="field-help">Eine HTTPS-Adresse je Zeile, höchstens zwanzig. Die sozialen Profile unten stehen automatisch mit in den strukturierten Daten.</p>
<fieldset class="field-set">
<legend>Soziale Profile</legend>
{% set profile = settings.social|default({})|filter(v => v)|length %}
<details class="field-set field-fold"{{ profile ? ' open' : '' }}>
<summary>Soziale Profile <span class="muted">{{ profile ? profile ~ ' eingetragen' : 'keine eingetragen' }}</span></summary>
<p class="field-help">Einmal hier eintragen, dann mit dem Element „Soziale Profile“ an jeder Stelle einsetzen – im Kopf, im Fuß, in einer Kontaktsektion. Größe, Form und Farbe bestimmt das Element. Leere Felder erscheinen nicht.</p>
<div class="fields">
{% for key, name in socialNetworks %}
@@ -157,9 +171,10 @@
</div>
{% endfor %}
</div>
</fieldset>
<fieldset class="field-set">
<legend>Öffnungszeiten</legend>
</details>
{% set zeiten = settings.openingHours|default([])|filter(h => h.opens|default('') or h.closes|default(''))|length %}
<details class="field-set field-fold"{{ zeiten ? ' open' : '' }}>
<summary>Öffnungszeiten <span class="muted">{{ zeiten ? zeiten ~ ' Tag(e) eingetragen' : 'keine eingetragen' }}</span></summary>
<p class="field-help">Als Uhrzeit wie 07:30. Ein Tag ohne Angabe gilt als geschlossen. Erscheint als <code>openingHoursSpecification</code> in den strukturierten Daten – der Text auf der Website ist davon unabhängig.</p>
<div class="fields">
{% for day, label in weekdays %}
@@ -170,7 +185,7 @@
</div>
{% endfor %}
</div>
</fieldset>
</details>
<label for="areaServed">Einsatzgebiet</label>
<textarea id="areaServed" name="areaServed" rows="3" placeholder="Baden-Baden&#10;Rastatt&#10;Karlsruhe">{{ settings.areaServed|join('\n') }}</textarea>
<p class="field-help">Ein Ort je Zeile, höchstens vierzig. Erscheint als <code>areaServed</code> in den strukturierten Daten.</p>
@@ -217,7 +232,7 @@
</div>
</div>
<p class="field-help">Das Bild kommt aus der Mediathek, dort kann es auch gleich hochgeladen werden. Danach das Quadrat auf den gewünschten Ausschnitt ziehen und die Größe einstellen. Beim Speichern entstehen daraus PNG-Dateien in 32 und 180 Pixeln.</p>
<div class="section-head"><h2>Knopf „nach oben"</h2><span class="muted">Erscheint beim Scrollen, in der Ecke</span></div>
<div class="section-head"><h2 id="nach-oben">Knopf „nach oben"</h2><span class="muted">Erscheint beim Scrollen, in der Ecke</span></div>
<label class="check-label" for="scrollTop"><input id="scrollTop" name="scrollTop" type="checkbox" value="1"{{ settings.scrollTop ? ' checked' : '' }}> Knopf „nach oben" anzeigen</label>
<p class="field-help">Springt an den Seitenanfang. Er braucht kein JavaScript: In Browsern mit Scroll-Zeitachse blendet er sich beim Scrollen ein, in den übrigen steht er von Anfang an da.</p>
<div class="fields">
@@ -260,7 +275,7 @@
</div>
</div>
<p class="field-help">Farben entweder als <code>@schlüssel</code> aus der Palette oder als Wert wie <code>#1a2b3c</code>. Leer bedeutet: Akzentfarbe des Designs auf Weiß.</p>
<div class="section-head"><h2>Spamschutz für Formulare</h2><span class="muted">Optional, von Cloudflare</span></div>
<div class="section-head"><h2 id="spamschutz">Spamschutz für Formulare</h2><span class="muted">Optional, von Cloudflare</span></div>
<p class="field-help">Eingebaut sind bereits drei Prüfungen ohne Dritte: ein verstecktes Feld, ein signierter Zeitstempel und höchstens fünf Absendungen je Absender und Stunde. Das hält automatisch ausgefüllte Formulare ab. Gegen jemanden, der einen echten Browser fernsteuert, hilft <strong>Cloudflare Turnstile</strong> – die Prüfung ohne Bilderrätsel. Der Preis: das Widget braucht JavaScript (ohne Skript ist das Formular dann nicht absendbar), und die IP-Adresse des Absenders geht an Cloudflare, was in die Datenschutzerklärung gehört. Schlüssel gibt es kostenlos im Cloudflare-Konto unter „Turnstile“.</p>
<label class="check-label" for="turnstile"><input id="turnstile" name="turnstile" type="checkbox" value="1"{{ settings.turnstile ? ' checked' : '' }}> Turnstile-Prüfung in Formularen anzeigen</label>
<label for="turnstileSiteKey">Öffentlicher Schlüssel (Site Key)</label>
@@ -271,7 +286,7 @@
<label for="robotsExtra">Zusätzliche Zeilen für robots.txt</label>
<textarea id="robotsExtra" name="robotsExtra" rows="8">{{ settings.robotsExtra }}</textarea>
<p class="field-help">Nur Kommentarzeilen und einfache Anweisungen wie <code>Disallow: /pfad</code>, <code>Allow:</code>, <code>User-agent:</code> und <code>Crawl-delay:</code>. Leerzeilen trennen Blöcke, höchstens 200 Zeilen. Hier stehen zum Beispiel eigene Regeln je Bot – etwa welche KI-Crawler die Website lesen dürfen und welche nicht.</p>
<div class="section-head"><h2>Einwilligung</h2><span class="muted">Nur nötig, wenn die Website etwas von Dritten lädt</span></div>
<div class="section-head"><h2 id="einwilligung">Einwilligung</h2><span class="muted">Nur nötig, wenn die Website etwas von Dritten lädt</span></div>
<label class="check-label" for="stickyHeader"><input id="stickyHeader" name="stickyHeader" type="checkbox" value="1"{{ settings.stickyHeader ? ' checked' : '' }}> Kopfbereich beim Scrollen oben halten</label>
<label class="check-label" for="headerOverlay"><input id="headerOverlay" name="headerOverlay" type="checkbox" value="1"{{ settings.headerOverlay ? ' checked' : '' }}> Kopfbereich über dem Inhalt schweben lassen</label>
<p class="field-help">Der Kopfbereich liegt dann über der ersten Sektion, wie der transparente Header in Flatsome. Seine Farben für den gescrollten Zustand stellst du im Editor des Kopfbereichs unter Design → Hintergrund „beim Scrollen“ ein; zusammen mit „oben halten“ bleibt er beim Scrollen sichtbar.</p>
@@ -327,7 +342,7 @@
<input id="consentInk" name="consentInk" value="{{ settings.consentInk }}" placeholder="@ink" data-color data-palette="{{ palette|json_encode|e('html_attr') }}">
</div>
<div>
<label for="consentAccent">Farbe des Knopfes „Alle akzeptieren“</label>
<label for="consentAccent">Farbe der Knöpfe</label>
<input id="consentAccent" name="consentAccent" value="{{ settings.consentAccent }}" placeholder="@accent" data-color data-palette="{{ palette|json_encode|e('html_attr') }}">
<p class="field-help">Färbt auch den Streifen oben, das Symbol und die Kästchen.</p>
</div>
@@ -336,7 +351,7 @@
<input id="consentAccentInk" name="consentAccentInk" value="{{ settings.consentAccentInk }}" placeholder="#ffffff" data-color data-palette="{{ palette|json_encode|e('html_attr') }}">
</div>
</div>
<p class="field-help">Leer heißt: Papier, Tinte und Akzent aus dem Design. „Nur notwendige“ bleibt bewusst ein Rahmenknopf in der Schriftfarbe – beide Antworten sind gleich groß, keine wird versteckt.</p>
<p class="field-help">Leer heißt: Papier, Tinte und Akzent aus dem Design. „Alle akzeptieren“ und „Nur notwendige“ bekommen bewusst dieselbe Farbe und Größe – eine hervorgehobene Zustimmung gilt als Lenkung.</p>
</fieldset>
<div class="fields">
<div>
@@ -349,14 +364,19 @@
</div>
<p class="field-help">Die Entscheidung liegt in einem Cookie dieser Website, ein halbes Jahr lang, ohne Kennung und ohne Übermittlung. Unter <code>/einwilligung</code> kann sie jederzeit geändert werden; auf jeder Seite steht dafür ein Link.</p>
<button class="primary" type="submit">Einstellungen speichern</button>
{# Bleibt am unteren Rand stehen, solange das Formular zu sehen ist: Der Knopf lag vorher
mehrere Bildschirmhöhen unter dem Feld, das man gerade geändert hatte. #}
<div class="form-save-bar">
<button class="primary" type="submit">Einstellungen speichern</button>
<span class="muted">Gilt für alles von „Unternehmensdaten“ bis „Einwilligung“.</span>
</div>
</form>
{% else %}
<p class="card card-body muted">Unternehmensdaten, Knopf „nach oben“, Spamschutz und Einwilligung ändert nur ein Administrator.</p>
{% endif %}
</section>
<section class="stack-section">
<section class="stack-section" id="weiterleitungen">
<div class="section-head"><h2>Weiterleitungen <span class="count">{{ redirects.total }}</span></h2><span class="muted">Werden vor dem 404 geprüft.</span></div>
<form action="{{ path('admin_redirect_save') }}" method="post" class="card card-body">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
+3 -2
View File
@@ -65,7 +65,8 @@
Betrachter den Klick ab - die Seite bleibt also in jedem Fall benutzbar. #}
<a class="image-link image-zoom" href="{{ grossesBild }}" data-zoom data-caption="{{ props.caption|default('') }}" aria-label="{{ props.alt|default('Bild'|kant_t) }} {{ 'vergrößern'|kant_t }}">
{%- elseif imageLinked -%}
<a class="image-link" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>
{# Ein Bild ohne Alternativtext - ein dekoratives Logo etwa - gäbe dem Link keinen Namen. #}
<a class="image-link" href="{{ props.href }}"{% if props.alt|default('') == '' %} aria-label="{{ props.caption|default('') ?: (props.href == '/' ? 'Startseite'|kant_t : 'Link'|kant_t ~ ': ' ~ props.href) }}"{% endif %}{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>
{%- endif -%}<img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="{{ eager or inHeader|default(false) ? 'eager' : 'lazy' }}"{{ eager ? ' fetchpriority="high"' : '' }} decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="{{ imageSizes }}"{% endif %}>{% if klick == 'vergroessern' or imageLinked %}</a>{% endif %}{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
{% elseif type == 'iconcard' %}
<div id="{{ block.id }}" class="block-iconcard{{ designClasses }}">
@@ -298,7 +299,7 @@
{% elseif type == 'postlist' %}
<div id="{{ block.id }}" class="block-postlist{{ designClasses }}">
{% set feed = postLists[block.id]|default([]) %}
{% include 'blocks/post-list.html.twig' with {editing: editing|default(false), posts: feed.posts|default(feed), pages: feed.pages|default(1), page: feed.page|default(1), total: feed.total|default(null), layout: block.props.layout|default('karten'), showMeta: block.props.showMeta ?? true, showExcerpt: block.props.showExcerpt ?? true, showTerms: block.props.showTerms ?? true} only %}
{% include 'blocks/post-list.html.twig' with {editing: editing|default(false), posts: feed.posts|default(feed), pages: feed.pages|default(1), page: feed.page|default(1), total: feed.total|default(null), layout: block.props.layout|default('karten'), showMeta: block.props.showMeta ?? true, showExcerpt: block.props.showExcerpt ?? true, showTerms: block.props.showTerms ?? true, headingLevel: block.props.headingLevel|default(3) == 2 ? 2 : 3} only %}
</div>
{% elseif type == 'postnav' %}
<div id="{{ block.id }}" class="block-postnav{{ designClasses }}">
+3 -1
View File
@@ -18,7 +18,9 @@
</a>
{% endif %}
<div class="post-body">
<h3><a href="{{ post.href }}">{{ post.title }}</a></h3>
{# Im Archiv folgt die Liste direkt auf die Hauptüberschrift, dort ist ein Beitrag eine
Überschrift zweiter Ebene. In einer Seite steht sie unter einer Abschnittsüberschrift. #}
<h{{ headingLevel|default(3) }}><a href="{{ post.href }}">{{ post.title }}</a></h{{ headingLevel|default(3) }}>
{% if (showMeta ?? true) and (post.publishedAt or post.author) %}
<p class="post-meta">
{% if post.publishedAt %}<time datetime="{{ post.publishedAt|date('Y-m-d') }}">{{ post.publishedAt|date('d.m.Y') }}</time>{% endif %}
+4
View File
@@ -103,6 +103,10 @@ final class BacklogTest extends TestCase
self::assertStringContainsString('<h1', $html);
self::assertStringContainsString('Beiträge', $html);
self::assertStringContainsString('Aus der Fußsektion', $html, 'Der Fuß samt verknüpfter Sektion steht unter dem Archiv.');
// Auf die Hauptüberschrift folgt im Archiv die zweite Ebene, nicht die dritte - sonst
// springt die Gliederung, und Lighthouse zieht dafür Punkte bei der Barrierefreiheit ab.
self::assertMatchesRegularExpression('#<div class="post-body">\s*<h2><a href="/[a-z0-9-]+">#', $html);
self::assertStringNotContainsString('<h3><a', $html);
self::assertStringContainsString('#fuss-hinweis p{letter-spacing:0.3em}', $html, 'Eigenes CSS der Sektion kommt mit.');
self::assertStringContainsString('scroll-top', $html, 'Der Nach-oben-Knopf ist da wie auf jeder Seite.');
self::assertStringContainsString('<link rel="canonical" href="https://cms.test/beitraege">', $html);
+8
View File
@@ -1035,6 +1035,14 @@ final class CmsTest extends TestCase
self::assertStringContainsString('<a class="image-link" href="/">', $html);
self::assertStringContainsString('alt="Firmenlogo"', $html);
// Ein dekoratives Logo hat keinen Alternativtext - der Link bekäme sonst keinen Namen.
$document['blocks'][array_key_last($document['blocks'])]['props'] = ['src' => '/media/'.str_repeat('a', 32).'/480', 'alt' => '', 'decorative' => true, 'width' => 480, 'height' => 120, 'href' => '/'];
$document['blocks'][] = ['id' => 'siegel', 'type' => 'image', 'props' => ['src' => '/media/'.str_repeat('b', 32).'/480', 'alt' => '', 'decorative' => true, 'width' => 480, 'height' => 120, 'href' => '/kontakt']];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 2), 3);
$html = $this->request('/')->getContent();
self::assertStringContainsString('<a class="image-link" href="/" aria-label="Startseite">', $html);
self::assertStringContainsString('<a class="image-link" href="/kontakt" aria-label="Link: /kontakt">', $html);
// Datum, Autor und Kategorie über einem Beitrag lassen sich abschalten - ein Beitrag mit
// eigenem Kopfbild trägt den Titel schon, und die Zeile darüber steht dann im Weg.
$this->publishedPost('projekt', 'Ein Projekt', '2026-02-01', ['Projekte'], []);