Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a33090905 | ||
|
|
6e18a476c2 | ||
|
|
3e6cd680f6 | ||
|
|
84cdf6a261 | ||
|
|
86da83f754 | ||
|
|
8b6b53fe0f | ||
|
|
732b86897c | ||
|
|
cecb0771db | ||
|
|
b08862b139 | ||
|
|
5e46c98825 | ||
|
|
4c663d8087 |
No files matched your search
@@ -40,6 +40,11 @@ Der Entwicklungsplan steht in ENTWICKLUNGSPLAN.md. Produktname und Stack sind no
|
||||
- Nur tatsächlich ausgeführte Prüfungen als bestanden melden. Fehlende Zugriffe oder nicht ausführbare Prüfungen klar festhalten.
|
||||
- Keine garantierten SEO-Rankings oder KI-Zitierungen versprechen.
|
||||
|
||||
## Arbeitsweise mit Codex
|
||||
|
||||
- Claude baut, Codex prüft nur lesend: `.\scripts\codex-review.ps1` (seit dem letzten Release-Tag), mit `-Seit <ref>`, `-Commit <sha>`, `-Offen` oder `-Alles`. Sinnvoll vor einem Release und nach Änderungen an Rechten, Speichern/Publizieren, Migrationen, Updater oder Formularversand.
|
||||
- Befunde von Codex sind Hinweise, keine Wahrheit: jeden am Code nachvollziehen, echte Fehler mit Regressionstest beheben, verworfene kurz begründen.
|
||||
|
||||
## Zusammenarbeit und Übergabe
|
||||
|
||||
- Mit Umut auf Deutsch, klar und direkt kommunizieren.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -298,6 +298,16 @@ Die öffentliche Adresse der Website steht als `SITE_ORIGIN` in `.env` – mit o
|
||||
|
||||
`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.
|
||||
|
||||
**Zweitprüfung mit Codex:** Claude baut, Codex prüft ab und zu nur lesend – in einer sauberen Git-Kopie ohne `.env`, `var/` und Kundendaten. Die Prüfregeln stehen in `scripts/codex-review-prompt.md`; vorausgesetzt ist die angemeldete Codex-CLI (`codex`). Die Schlussantwort steht am Ende der Ausgabe und zusätzlich unter `reports/codex-review-<Datum>.md`.
|
||||
|
||||
```powershell
|
||||
.\scripts\codex-review.ps1 # alles seit dem letzten Release-Tag
|
||||
.\scripts\codex-review.ps1 -Seit v1.42.0 # seit einem Tag oder Commit
|
||||
.\scripts\codex-review.ps1 -Commit <sha> # ein einzelner Commit
|
||||
.\scripts\codex-review.ps1 -Offen # noch nicht committete Änderungen (ohne neue, unversionierte Dateien)
|
||||
.\scripts\codex-review.ps1 -Alles # gesamter Code
|
||||
```
|
||||
|
||||
Das Lighthouse-Skript misst dreimal je Profil und prüft die Mediane gegen die Projektziele. Welche Adressen geprüft werden, sagt `CMS_PATHS` (mit Komma getrennt, Standard `/`); in der Git-Bash davor `MSYS_NO_PATHCONV=1` setzen, sonst wird aus `/` ein Windows-Pfad. `test:editor -- --audit` prüft die im isolierten Editortest gestaltete v3-Seite mit Abschnittsvorlage, Galerie, tatsächlichen Bildvarianten und Effekten; Berichte stehen unter `reports/lighthouse-editor`. Uploads und Datenbank des Tests liegen getrennt unter `var/browser-tests`. Diese kleinen Prototypseiten ersetzen nicht die spätere Referenzsuite mit Kundeninhalten, Fotos, Beitrag und Formular. Messgrenzen und Ergebnisse: `docs/QUALITY.md`.
|
||||
|
||||
## Inhalte und Entwicklung
|
||||
|
||||
+18
-2
@@ -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.
|
||||
@@ -40,7 +56,7 @@ Stand: 10.11.2026. Arbeitstitel KantSites; Produktname offen.
|
||||
|
||||
**Entscheidung:** Eigene Tabelle `video` und `VideoLibrary` nach dem Vorbild der Dokumente, Dateien als `<id>.mp4`/`<id>.webm` im Medienordner, bis 64 MB, Typ nach den ersten Bytes (MOV abgewiesen). Ausgeliefert unter `/media/<id>.<endung>` mit Range-Anfragen und `default-src 'none'`. Im Dokument stehen Adressen, nicht Kennungen: Baustein `video` (`src`, `srcWebm`, `poster`, `title`, `mode`: `player`/`loop`) und an der Sektion `props.backgroundVideo`/`backgroundVideoWebm`. Der Validator lässt nur Adressen der eigenen Mediathek zu; fremde Videos bleiben Sache von „Externer Inhalt“ mit Einwilligung.
|
||||
|
||||
Schleifen stehen ohne `autoplay` im HTML, mit `preload="none"`. `video.js` startet sie, solange sie zu sehen sind, und zeigt den Knopf zum Anhalten (WCAG 2.2.2). Bei „Bewegung reduzieren“ oder Datensparen bleibt das Standbild, starten lässt es sich von Hand. Das Skript kommt nur auf Seiten mit Schleife; ein Spieler lässt `script-src 'none'` stehen.
|
||||
Schleifen stehen ohne `autoplay` im HTML, mit `preload="none"`. `video.js` startet sie, solange sie zu sehen sind, und zeigt den Knopf zum Anhalten (WCAG 2.2.2). Bei „Bewegung reduzieren“ oder Datensparen bleibt das Standbild, starten lässt es sich von Hand. Verweigert der Browser den Start (`NotAllowedError`), holt die erste Berührung oder Taste ihn nach; ein Klick auf den Knopf zählt dabei nur als dessen eigene Entscheidung. Das Skript kommt nur auf Seiten mit Schleife; ein Spieler lässt `script-src 'none'` stehen.
|
||||
|
||||
**Folgen:** Kein Umrechnen auf dem Server: Wer zu große Dateien hochlädt, bekommt zu große Dateien, deshalb der Hinweis im Dialog. Die PHP-Grenzen `upload_max_filesize` und `post_max_size` des Hostings gelten zusätzlich. Die Überlagerung einer Sektion ist Teil ihres CSS-Hintergrunds und liegt damit unter dem Video. Website-Pakete von vor v28 bleiben importierbar, die Tabelle gilt dort als leer.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+72
-1
@@ -1,5 +1,72 @@
|
||||
# Projektstatus
|
||||
|
||||
## Codex als Zweitprüfer (11.11.2026, noch nicht committet)
|
||||
|
||||
**Anlass:** Umut: Codex-CLI wie bei Domira als Bugprüfer einrichten, ab und zu mitlaufen lassen.
|
||||
|
||||
**Erledigt:** `scripts/codex-review.ps1` (nach Domira-Vorbild: nur lesend, `--ephemeral`, saubere Git-Kopie ohne `.env`/`var/`) mit den Umfängen seit letztem Release-Tag (Standard), `-Seit`, `-Commit`, `-Offen`, `-Alles`; Schlussantwort zusätzlich unter `reports/codex-review-<Datum>.md`. Prüfregeln für KantSites in `scripts/codex-review-prompt.md`. README (Prüfen) und AGENTS.md (Arbeitsweise mit Codex) ergänzt.
|
||||
|
||||
**Geprüft:** Probelauf `-Commit 84cdf6a` mit codex-cli 0.167.1: läuft durch (Exit 0, rund 47 000 Tokens), Antwort auf Deutsch („keine konkret belegbaren Fehler“), Kopie danach entfernt. Skript fehlerfrei geparst, reines ASCII. **Nicht geprüft:** `-Offen`, `-Alles` und die Ablage unter `reports/` (nach dem Probelauf ergänzt) in einem echten Lauf.
|
||||
|
||||
**Nächster Schritt:** vor dem nächsten Release `.\scripts\codex-review.ps1` laufen lassen und die Befunde nachvollziehen.
|
||||
|
||||
## Luximmo: Instagram und Facebook im Fuß (11.11.2026, noch nicht committet, nicht live)
|
||||
|
||||
**Erledigt:** `seed-luximmo.php`: Baustein `fuss-profile` (runde Symbole, Instagram und Facebook) unter dem Spruch im Fuß; Adressen aus `LUXIMMO_INSTAGRAM`/`LUXIMMO_FACEBOOK`, sonst leer und unsichtbar. Datenbank und `var/luximmo-paket.zip` neu gebaut (ohne Adressen).
|
||||
|
||||
**Geprüft:** In einer Kopie der Datenbank mit Beispieladressen: beide Links im Fuß, neuer Tab, Namen für Screenreader, 39×39 px. **Offen:** echte Profiladressen (nicht auf lux-immo.net und nicht im Netz gefunden) – live unter Einstellungen → Unternehmensdaten → Soziale Profile eintragen.
|
||||
|
||||
## Luximmo: Autostart, Symbole im Ablauf, Linien der Leistungen (11.11.2026, noch nicht committet, nicht live)
|
||||
|
||||
**Anlass:** Umut: Video startet nicht von selbst, Ziffern im Ablauf ersetzen, Aufklapp-Linien bei den Leistungen auf eine Höhe.
|
||||
|
||||
**Erledigt:** `video.js` (Kern): Verweigert der Browser den Start, startet das Video bei der ersten Berührung oder Taste. `seed-luximmo.php`: Symbole statt Ziffern im Ablauf (Brief, Personen, Dokument, Schlüssel), Leistungen als Subgrid. Lokale Datenbank und `var/luximmo-paket.zip` neu gebaut.
|
||||
|
||||
**Geprüft (Playwright, 8108):** Linien bei 1440 und 2000 px auf einer Höhe, bei 900 px je Reihe; ein geöffneter Eintrag verschiebt die Nachbarn nicht; kein Querscrollen bis 390 px. Start ohne Eingabe läuft; simulierte Verweigerung startet nach dem ersten Klick, der Knopf hält danach an. Nicht geprüft: `test:browser`, Lighthouse, PHPUnit, Firefox, Safari.
|
||||
|
||||
**Bekannte Fehler / offen:** Umut muss auf seinem Gerät Play drücken. Nicht nachgestellt: lux-immo.net startet von selbst in Chrome (mit Profil) und frisch in Chrome, Edge und Chromium unter Windows. Bei „Bewegung reduzieren“, Datensparen oder einer Browser-Sperre bleibt der Autostart aus; Video nur als WebM.
|
||||
|
||||
**Nächster Schritt:** Browser und Gerät von Umut klären, danach Release und Paket-Import für die Live-Seite.
|
||||
|
||||
## 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).
|
||||
|
||||
**Veröffentlicht:** Tag `v1.43.1` gepusht, Release-Eintrag auf git.kant-it.de mit Zip, `.sha256` und `install.php`; anonymer Download stimmt mit der Prüfsumme überein.
|
||||
|
||||
**Nächster Schritt:** Hüfner aktualisieren, Datenschutzerklärung um `kant_consent` ergänzen, 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 +89,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)
|
||||
|
||||
|
||||
@@ -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 } }
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
+21
-4
@@ -16,6 +16,23 @@ for (const video of document.querySelectorAll('[data-video-loop]')) {
|
||||
if (!toggle) continue;
|
||||
let wanted = !reduced.matches && !saving;
|
||||
let visible = false;
|
||||
// Verweigert der Browser den Start, holt die erste Berührung oder Taste ihn nach. Der Knopf
|
||||
// entscheidet selbst: Sein Klick würde das nachgeholte Abspielen sonst gleich wieder anhalten.
|
||||
const retry = event => {
|
||||
if (toggle.contains(event.target)) return;
|
||||
stopWaiting();
|
||||
wanted = true;
|
||||
paint();
|
||||
sync();
|
||||
};
|
||||
const waitForGesture = () => {
|
||||
addEventListener('pointerup', retry);
|
||||
addEventListener('keydown', retry);
|
||||
};
|
||||
const stopWaiting = () => {
|
||||
removeEventListener('pointerup', retry);
|
||||
removeEventListener('keydown', retry);
|
||||
};
|
||||
const paint = () => {
|
||||
toggle.classList.toggle('is-paused', !wanted);
|
||||
toggle.setAttribute('aria-label', wanted ? toggle.dataset.pause : toggle.dataset.play);
|
||||
@@ -24,13 +41,13 @@ for (const video of document.querySelectorAll('[data-video-loop]')) {
|
||||
if (!wanted || !visible) { video.pause(); return; }
|
||||
video.play().catch(error => {
|
||||
// Ein Anhalten mitten im Starten ist kein Fehler. Verweigert der Browser das Abspielen,
|
||||
// etwa im Stromsparmodus, zeigt der Knopf die Wahrheit.
|
||||
if (error.name === 'NotAllowedError') { wanted = false; paint(); }
|
||||
// etwa im Stromsparmodus, zeigt der Knopf die Wahrheit, bis eine Eingabe den Start erlaubt.
|
||||
if (error.name === 'NotAllowedError') { wanted = false; paint(); waitForGesture(); }
|
||||
});
|
||||
};
|
||||
toggle.hidden = false;
|
||||
toggle.addEventListener('click', () => { wanted = !wanted; paint(); sync(); });
|
||||
reduced.addEventListener('change', () => { if (reduced.matches) { wanted = false; paint(); sync(); } });
|
||||
toggle.addEventListener('click', () => { stopWaiting(); wanted = !wanted; paint(); sync(); });
|
||||
reduced.addEventListener('change', () => { if (reduced.matches) { stopWaiting(); wanted = false; paint(); sync(); } });
|
||||
new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; sync(); }).observe(video);
|
||||
paint();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
Du prüfst Code von KantSites, einem selbst betriebenen CMS für Kundenwebsites (PHP 8.4, Symfony, Doctrine, Twig, SQLite oder MySQL/MariaDB, Shared Hosting ohne Node). Die Regeln stehen in AGENTS.md, Entscheidungen in docs/ARCHITECTURE.md. Zusätzlich gilt: keine nativen Browser-Dialoge (`confirm()`, `alert()`, `prompt()`); Rückfragen laufen über einen Dialog der Anwendung.
|
||||
|
||||
Ändere nichts. Melde nur echte Fehler, bei denen du ein konkretes Fehlerszenario benennen kannst:
|
||||
|
||||
1. Sicherheit: Umgehung von Anmeldung oder Rollen in der Verwaltung, fehlende Rechteprüfung, CSRF, XSS (zum Beispiel `|raw` in Twig, Inhalte aus Blöcken, Formularen oder Plugins), offene Redirects, Pfadmanipulation bei Uploads, Medien, Inhaltspaketen und Updater, Verstöße gegen die Schutzkopfzeilen.
|
||||
2. Korrektheit: Logikfehler beim Speichern, Publizieren und Laden von Seiten, Verlust von Entwürfen, nicht erkannte veraltete Schreibzugriffe (`StaleWrite`), fehlerhafte Migrationen von Dokumentschema oder Datenbank, Unterschiede zwischen SQLite und MySQL, veralteter Cache nach dem Publizieren, Fehler in der Übersetzungsebene, im Formularversand und im Updater.
|
||||
3. Öffentliche Ausgabe: Editorcode im öffentlichen Paket, fehlender Hauptinhalt im HTML, Rückschritte bei Barrierefreiheit (fehlende Beschriftungen, Tastaturbedienung, Kontrast), Leistung (LCP, CLS, render-blockierende Ressourcen) und SEO (Canonical, Sitemap, strukturierte Daten).
|
||||
4. Fehlende Tests für riskante Pfade.
|
||||
5. Verstöße gegen die Regeln in AGENTS.md, zum Beispiel native Browser-Dialoge.
|
||||
|
||||
Keine Stilfragen, keine Vorschläge für Refactorings. Die Skripte `scripts/seed-*.php` und `scripts/reset-*.sh` bauen Referenzwebsites mit absichtlich festen Inhalten auf. Ignoriere `vendor/`, `node_modules/`, `public/assets/build/`, `public/assets/fonts/`, `composer.lock` und `package-lock.json`.
|
||||
|
||||
Antworte auf Deutsch. Für jeden Befund: Datei und Zeile, Schwere (hoch, mittel, niedrig), konkretes Fehlerszenario, Vorschlag zur Behebung. Wenn nichts zu melden ist, sag das in einem Satz.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Laesst Codex einen Stand pruefen, nur lesend und in einer sauberen Kopie ohne .env.
|
||||
# Die Datei bleibt reines ASCII, weil Windows PowerShell 5.1 sonst Umlaute falsch liest.
|
||||
# .\scripts\codex-review.ps1 alles seit dem letzten Release-Tag
|
||||
# .\scripts\codex-review.ps1 -Seit v1.42.0 alles seit einem Tag oder Commit
|
||||
# .\scripts\codex-review.ps1 -Commit <sha> einen einzelnen Commit
|
||||
# .\scripts\codex-review.ps1 -Offen die noch nicht committeten Aenderungen
|
||||
# .\scripts\codex-review.ps1 -Alles den gesamten Code
|
||||
param(
|
||||
[string]$Seit = "",
|
||||
[string]$Commit = "",
|
||||
[switch]$Offen,
|
||||
[switch]$Alles
|
||||
)
|
||||
|
||||
$utf8 = New-Object System.Text.UTF8Encoding $false
|
||||
$OutputEncoding = $utf8
|
||||
[Console]::OutputEncoding = $utf8
|
||||
|
||||
$repo = (git rev-parse --show-toplevel).Trim()
|
||||
$regeln = Get-Content -Raw -Encoding UTF8 (Join-Path $repo "scripts\codex-review-prompt.md")
|
||||
$ziel = if ($Commit) { $Commit } else { "HEAD" }
|
||||
|
||||
if ($Alles) {
|
||||
$umfang = "Scope: the whole code under src/, templates/, public/assets/ (without build/ and fonts/), plugins/, migrations/, config/ and zentrale/."
|
||||
} elseif ($Commit) {
|
||||
$umfang = "Scope: the changes from: git show $Commit"
|
||||
} elseif ($Offen) {
|
||||
$umfang = "Scope: the uncommitted changes, visible with: git diff HEAD (new untracked files are not included)."
|
||||
} else {
|
||||
if (-not $Seit) { $Seit = (git -C $repo describe --tags --abbrev=0 HEAD).Trim() }
|
||||
if ($LASTEXITCODE -ne 0 -or -not $Seit) { Write-Error "Kein Release-Tag gefunden, bitte -Seit angeben."; exit 1 }
|
||||
$umfang = "Scope: the changes from: git diff $Seit..HEAD"
|
||||
}
|
||||
Write-Host $umfang
|
||||
|
||||
$kopie = Join-Path $env:TEMP ("kantsites-review-" + [guid]::NewGuid().ToString("N").Substring(0, 8))
|
||||
|
||||
git -C $repo worktree add --quiet --detach $kopie $ziel
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
|
||||
try {
|
||||
if ($Offen) {
|
||||
$patch = Join-Path $env:TEMP ("kantsites-review-" + [guid]::NewGuid().ToString("N").Substring(0, 8) + ".patch")
|
||||
git -C $repo diff HEAD --binary --output="$patch"
|
||||
if ((Get-Item $patch).Length -eq 0) { Write-Host "Keine offenen Aenderungen."; Remove-Item $patch; exit 0 }
|
||||
git -C $kopie apply --whitespace=nowarn "$patch"
|
||||
$angewendet = $LASTEXITCODE
|
||||
Remove-Item $patch
|
||||
if ($angewendet -ne 0) { Write-Error "Offene Aenderungen liessen sich nicht in die Kopie uebernehmen."; exit $angewendet }
|
||||
}
|
||||
$berichte = Join-Path $repo "reports"
|
||||
if (-not (Test-Path $berichte)) { New-Item -ItemType Directory $berichte | Out-Null }
|
||||
$bericht = Join-Path $berichte ("codex-review-" + (Get-Date -Format "yyyy-MM-dd-HHmm") + ".md")
|
||||
"$umfang`n`n$regeln" | codex exec --sandbox read-only --ephemeral --cd $kopie --output-last-message $bericht -
|
||||
$status = $LASTEXITCODE
|
||||
if (Test-Path $bericht) {
|
||||
Write-Host "`n===== Ergebnis (auch in $bericht) ====="
|
||||
Get-Content -Raw -Encoding UTF8 $bericht | Write-Host
|
||||
}
|
||||
} finally {
|
||||
git -C $repo worktree remove --force $kopie
|
||||
}
|
||||
exit $status
|
||||
@@ -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',
|
||||
|
||||
+27
-13
@@ -494,6 +494,11 @@ $fuss = [
|
||||
$column('fuss-marke', [
|
||||
['id' => 'fuss-logo', 'type' => 'html', 'props' => ['html' => $logoSvg('role="img" aria-label="'.$marke.'"')], 'style' => ['desktop' => ['width' => '96px', 'maxWidth' => '96px', 'margin' => $seiten('0px')]], 'css' => $logoCss],
|
||||
$text('fuss-spruch', 'Ihr neuer Immobilienpartner vor Ort. Ein Lichtblick für Ihre Immobilie.', ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.6, 'color' => '@text', 'maxWidth' => '280px', 'margin' => ['top' => '20px', 'bottom' => '0px']], 'mobile' => ['maxWidth' => 'none']]),
|
||||
// Adressen aus Einstellungen → Unternehmensdaten → Soziale Profile; ohne Eintrag
|
||||
// bleibt der Baustein unsichtbar.
|
||||
['id' => 'fuss-profile', 'type' => 'social', 'props' => ['size' => 20, 'shape' => 'circle', 'layout' => 'horizontal', 'labels' => false, 'networks' => ['instagram', 'facebook']],
|
||||
'style' => ['desktop' => ['margin' => ['top' => '22px', 'bottom' => '0px']]],
|
||||
'css' => "& a{color:#141414;border:1px solid #E6DED2;transition:background-color .2s,color .2s,border-color .2s}\n& a:hover{background:#141414;color:#fff;border-color:#141414}"],
|
||||
]),
|
||||
$column('fuss-anschrift', [
|
||||
$kleinzeile('fuss-anschrift-titel', 'Adresse', '@gold-dunkel', '8px'),
|
||||
@@ -543,6 +548,8 @@ $vorteil = static fn (int $n, string $icon, string $titel, string $satz) => $col
|
||||
"transition:transform .3s ease,background-color .3s ease\n&:hover{transform:translateY(-4px);background-color:#F2ECE2}");
|
||||
|
||||
/** Eine Leistung: Foto, Titel, Satz, aufklappbar „Was dazugehört“. */
|
||||
// Die vier Teile liegen als Subgrid auf gemeinsamen Zeilen des Rasters: Die Aufklapp-Linien stehen so
|
||||
// auf einer Höhe, auch bei ungleich langen Sätzen, und ein geöffneter Eintrag verschiebt die anderen nicht.
|
||||
$leistung = static function (string $id, array $foto, string $alt, string $titel, string $satz, array $punkte) use ($column, $bild, $heading, $text, $nurOben): array {
|
||||
return $column($id, [
|
||||
$bild($id.'-bild', $foto, $alt, [
|
||||
@@ -551,7 +558,7 @@ $leistung = static function (string $id, array $foto, string $alt, string $titel
|
||||
'mobile' => ['imageHeight' => '260px'],
|
||||
], null, "overflow:hidden;border-radius:24px\n& img{display:block;width:100%;transition:transform .6s ease}\n&:hover img{transform:scale(1.04)}"),
|
||||
$heading($id.'-titel', $titel, 3, ['desktop' => ['fontSize' => '24px', 'fontWeight' => '500', 'letterSpacing' => '-0.02em', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '6px']]]),
|
||||
$text($id.'-satz', $satz, ['desktop' => ['fontSize' => '17px', 'lineHeight' => 1.55, 'color' => '@text', 'margin' => ['bottom' => '0px']]]),
|
||||
$text($id.'-satz', $satz, ['desktop' => ['fontSize' => '17px', 'lineHeight' => 1.55, 'color' => '@text', 'margin' => ['bottom' => '0px']]], 'overflow-wrap:break-word'),
|
||||
['id' => $id.'-mehr', 'type' => 'accordion', 'props' => ['faq' => false],
|
||||
'style' => ['desktop' => ['borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'margin' => ['top' => '20px']]],
|
||||
'children' => [
|
||||
@@ -564,13 +571,17 @@ $leistung = static function (string $id, array $foto, string $alt, string $titel
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
], ['tablet' => ['padding' => ['bottom' => '56px']]], 'display:grid;grid-row:span 4;grid-template-rows:subgrid;grid-template-columns:minmax(0,1fr);row-gap:0');
|
||||
};
|
||||
|
||||
/** Ein Schritt des Wechsels: große Ziffer in Gold, Titel, Satz. */
|
||||
$schritt = static fn (int $n, string $titel, string $satz) => $column('schritt-'.$n, [
|
||||
$text('schritt-'.$n.'-nummer', '0'.$n, ['desktop' => ['fontSize' => '15px', 'fontWeight' => '500', 'letterSpacing' => '0.08em', 'color' => '@gold-dunkel', 'margin' => ['bottom' => '28px']]],
|
||||
"display:flex;align-items:center;gap:12px\n&::after{content:'';flex:1;height:1px;background:#E6DED2}"),
|
||||
/** Ein Schritt des Wechsels: Symbol im weißen Kreis mit Linie zum nächsten Schritt, Titel, Satz. */
|
||||
$schritt = static fn (int $n, string $icon, string $titel, string $satz) => $column('schritt-'.$n, [
|
||||
$column('schritt-'.$n.'-kopf', [
|
||||
$column('schritt-'.$n.'-kreis', [$symbol('schritt-'.$n.'-icon', $icon, 24, ['desktop' => ['color' => '@gold-dunkel']])], ['desktop' => [
|
||||
'display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center', 'flexShrink' => 0, 'width' => '52px', 'height' => '52px', 'backgroundColor' => '@papier', 'borderRadius' => $rund('50%'),
|
||||
]]),
|
||||
], ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'gap' => '12px', 'margin' => ['bottom' => '24px']]],
|
||||
"&::after{content:'';flex:1;height:1px;background:#E6DED2}"),
|
||||
$heading('schritt-'.$n.'-titel', $titel, 3, ['desktop' => ['fontSize' => '22px', 'fontWeight' => '500', 'letterSpacing' => '-0.015em', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '10px']]]),
|
||||
$text('schritt-'.$n.'-satz', $satz, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['bottom' => '0px']]]),
|
||||
]);
|
||||
@@ -660,7 +671,7 @@ $startseite = [
|
||||
'Marktgerechte Bewertung',
|
||||
'Persönliche Begleitung bis zum erfolgreichen Verkauf',
|
||||
]),
|
||||
$leistung('leistung-weg', $medien['weg'], 'Wohnanlage mit mehreren Gebäuden und grünem Innenhof', 'WEG-Verwaltung', 'Strukturierte, transparente und zuverlässige Verwaltung von Wohnungseigentümergemeinschaften.', [
|
||||
$leistung('leistung-weg', $medien['weg'], 'Wohnanlage mit mehreren Gebäuden und grünem Innenhof', 'WEG-Verwaltung', "Strukturierte, transparente und zuverlässige Verwaltung von Wohnungseigentümer\u{AD}gemeinschaften.", [
|
||||
'Eigentümerversammlung vorbereiten, leiten, protokollieren',
|
||||
'Beschlüsse umsetzen und Beschlusssammlung führen',
|
||||
'Wirtschaftsplan und Jahresabrechnung',
|
||||
@@ -682,8 +693,9 @@ $startseite = [
|
||||
'Übergaben und Wohnungsabnahmen',
|
||||
]),
|
||||
], [
|
||||
'desktop' => ['display' => 'grid', 'gridColumns' => 4, 'columnGap' => '28px', 'rowGap' => '56px', 'alignItems' => 'flex-start'],
|
||||
'tablet' => ['gridColumns' => 2],
|
||||
'desktop' => ['display' => 'grid', 'gridColumns' => 4, 'columnGap' => '28px', 'rowGap' => '0px'],
|
||||
// Der Abstand zwischen den Reihen sitzt in den Karten, ein Zeilenabstand läge zwischen ihren Teilen.
|
||||
'tablet' => ['gridColumns' => 2, 'margin' => ['bottom' => '-56px']],
|
||||
'mobile' => ['gridColumns' => 1],
|
||||
]),
|
||||
], $abschnitt('0px', '0px'), 'leistungen', '', 'wide'),
|
||||
@@ -699,10 +711,10 @@ $startseite = [
|
||||
'mobile' => ['margin' => ['bottom' => '40px']],
|
||||
]),
|
||||
$column('wechsel-raster', [
|
||||
$schritt(1, 'Anfrage', 'Sie schildern uns kurz Ihr Objekt – online in drei Schritten oder im Gespräch.'),
|
||||
$schritt(2, 'Kennenlernen', 'Wir sehen uns das Objekt vor Ort an und klären, was Ihnen wichtig ist.'),
|
||||
$schritt(3, 'Angebot', 'Sie erhalten ein klares Angebot mit allen Leistungen – ohne Kleingedrucktes.'),
|
||||
$schritt(4, 'Übergabe', 'Wir übernehmen Unterlagen, Konten und Verträge und informieren Mieter und Eigentümer.'),
|
||||
$schritt(1, 'mail', 'Anfrage','Sie schildern uns kurz Ihr Objekt – online in drei Schritten oder im Gespräch.'),
|
||||
$schritt(2, 'people', 'Kennenlernen','Wir sehen uns das Objekt vor Ort an und klären, was Ihnen wichtig ist.'),
|
||||
$schritt(3, 'document', 'Angebot','Sie erhalten ein klares Angebot mit allen Leistungen – ohne Kleingedrucktes.'),
|
||||
$schritt(4, 'key', 'Übergabe','Wir übernehmen Unterlagen, Konten und Verträge und informieren Mieter und Eigentümer.'),
|
||||
], [
|
||||
'desktop' => ['display' => 'grid', 'gridColumns' => 4, 'gap' => '40px'],
|
||||
'tablet' => ['gridColumns' => 2, 'rowGap' => '48px'],
|
||||
@@ -1134,6 +1146,8 @@ $settings->save([
|
||||
'organisationLogoMediaId' => $medien['logo']['id'],
|
||||
'faviconMediaId' => $medien['favicon']['id'],
|
||||
'stickyHeader' => false, 'headerOverlay' => false,
|
||||
// Instagram und Facebook stehen im Fuß; leer bleiben sie unsichtbar.
|
||||
'social' => ['instagram' => getenv('LUXIMMO_INSTAGRAM') ?: '', 'facebook' => getenv('LUXIMMO_FACEBOOK') ?: ''],
|
||||
'motion' => 'always',
|
||||
'homePath' => 'home', 'imprintPath' => 'impressum', 'privacyPath' => 'datenschutz',
|
||||
// Die Seite lädt nichts von Dritten; deshalb keine Einwilligungsleiste.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]],
|
||||
]],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 Rastatt 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>
|
||||
|
||||
{# 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') }}">
|
||||
|
||||
@@ -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 }}">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'], []);
|
||||
|
||||
Reference in new issue
Block a user