Compare commits

...
8 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5 42e74af052 Release 1.21.6
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 Opus 5 <noreply@anthropic.com>
2026-09-18 07:43:11 +02:00
kantarciandClaude Opus 5 106f84dfd5 Pick the site icon from the library and crop it, find legal pages by search, list newest first
- The site icon is no longer a select at the end of the company data. It has
  its own section: a button opens the media library (upload included), then
  a square is dragged, resized by its corner, a slider or +/-, and previewed
  at 180 and 32 px. The crop is stored as fractions (faviconCrop) and the
  PNGs are cut from it; faviconVersion changes the link so browsers pick up
  a new cut. Icons without a crop keep their old link.
- openMedia() takes needsAlt: false, since the icon never appears as
  content. The dialog styles moved from editor.css to media-dialog.css so
  the dialog works outside the editor.
- Privacy page and imprint are chosen with the content search, like the 404
  page. The picker can store the path instead of the id (valueKey/data-value).
- The page and post lists in the administration sort newest first; the
  subpage indentation is gone there, as it would point at the wrong parent.

Tests: 581 PHPUnit, 35 node, test:editor green (the consent step now picks
the privacy page by search). test:admin --admin-only fails only at the known
nested-interactive menu summaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 07:43:05 +02:00
kantarciandClaude Fable 5.1 6632836580 Release 1.21.5
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.1 <noreply@anthropic.com>
2026-09-17 12:56:57 +02:00
kantarciandClaude Fable 5.1 d0019c80af Arrow icons, cascading own CSS, a preload for the hero image, and EB Solar
Built while setting up the redesign of ebsolar.de as the sixth local
installation (scripts/seed-ebsolar.php, reset-ebsolar.sh, ports 8096/8097).
The rebuild surfaced four things in the core:

- The icon set knows three arrows now: arrow-right, arrow-left,
  arrow-up-right. The button block and the post navigation render them as
  inline SVG inside the same aria-hidden span as before, so a template that
  hides the arrow with "& span{display:none}" keeps working. Until now the
  arrow was a text glyph that looked different in every font. Umut: "bei
  alle Projekte bzw bei allen möchte ich dieses emoji icon weg haben".

- Own CSS with the same nested selector twice kept only the last rule.
  "& .post-card{border:0}" followed by "& .post-card{transform:none}" lost
  the border - silently, which brought the core's card frame back on the
  project grid. The parser now joins the declarations in source order, as
  CSS does; the same for "&{…}" next to the element's own properties.

- The eager image over the fold was not preloaded. The background image of
  the first section has had a preload link for a while; a plain <img> was
  found only after the stylesheet was parsed, and that round trip went
  straight into LCP. PageController::heroImage() now writes the same src,
  srcset and sizes the block template writes, and page.html.twig emits the
  link - unless a background image is already preloaded.

- Measured on the way: an entrance effect on the LCP element costs about
  900 ms of render delay. The EB Solar hero image therefore has none.

Tests: 581 green, the icon test accepts quoted keys, two cases for the CSS
cascade, the preload asserted in the big-picture test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 12:56:57 +02:00
kantarciandClaude Fable 5.1 ab9e6a7ea0 Release 1.21.4
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.1 <noreply@anthropic.com>
2026-09-16 22:09:15 +02:00
kantarciandClaude Fable 5.1 8328bf0964 Give the scroll-to-top target a block box, and take the toolbar lift back out
Umut, after 1.21.3: "fix hat nichts gebracht" - and the fix could not have,
for two reasons. kant-it.de still runs 1.20.1, so nothing since then was ever
live there. And the diagnosis behind 1.21.3 rested on a test that proved
nothing: Safari's folded address bar shows only the domain, never the
fragment, so "nothing in the address bar" said nothing about whether the tap
had arrived.

Reproduced this time in the real engine: Playwright's WebKit with the iPhone 14
profile. The tap arrives, #seitenanfang lands in the URL, and scrollY stays at
1500. Setting location.hash directly does the same. In the desktop profile as
well, so this is Safari on the Mac too. Chromium jumps to 0.

Narrowed down with fourteen variants of the target: WebKit does not scroll to a
fragment target whose box is inline and has no area. The same empty span with
display:block jumps; an empty div with no height jumps; #top, #main and body
jump. It is the inline box, not the size. The section anchors are not affected
- position:absolute already blockifies them and they jump in WebKit.

So the target gets display:block, one rule in site.css. The template stays as
it is.

And the lift from 1.21.3 comes out again: --kant-browser-chrome and the six
places that added it to bottom or padding. It moved the button and the consent
bar up by the folded toolbar's height on every iPhone, for a cause that turned
out not to be the cause. On the desktop it was zero anyway.

WebKit iPhone 14 after the change: tap 0, click 0, hash 0. Chromium unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 22:06:58 +02:00
kantarciandClaude Opus 5 245f189d57 Release 1.21.3
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 Opus 5 <noreply@anthropic.com>
2026-09-16 21:56:12 +02:00
kantarciandClaude Opus 5 99b3aba39a Lift what sticks to the bottom above the browser's own bar
Umut: "Beim Smarphone/iphone geht der button scroll to top nicht. hier gehts."
The button was there and did nothing, on every page of every installation, and
tapping it did not even put #seitenanfang in the address bar - so the tap never
reached the page.

Safari on the iPhone folds its address bar into a narrow strip while reading
and lays that over the content. A fixed element measures its "bottom" against
the full viewport, not against the part that is left: the button stood in the
strip the browser had taken for itself. Drawn, visible, and every tap landed on
the bar. Measured at 390x844 the button occupies y 780 to 828 - the bottom 64
pixels, which is where that strip sits.

The difference between the large viewport (bar folded) and the small one (bar
out) is exactly that height, so --kant-browser-chrome carries it and the pieces
that stick to the bottom add it. Where there is no such bar it is zero and
nothing moves. The @supports around it is not caution but necessity: a browser
that does not know lvh/svh would find the calculation invalid at computed-value
time, and bottom would fall back to auto - the button would then stand
anywhere.

The consent bar has the same geometry, so "Annehmen" and "Ablehnen" were just
as hard to hit. It is lifted the same way, except where it is glued to the edge
- raising it there would leave a gap with the page showing through, so its
bottom padding grows instead and the buttons move up with it.

Verified with a test page that sets the height to 50px, the only way to see the
effect without an iPhone: the button moves up by exactly 50px, the glued bar
keeps its edge and its padding goes from 16px to 66px. On the real page in
Chromium bottom stays 16px and the jump still goes 1500 to 0.

Not verified on a real iPhone - that needs the device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 21:56:07 +02:00
32 changed files with 1780 additions and 82 deletions

No files matched your search

+21 -1
View File
@@ -25,7 +25,7 @@
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-1d19ca1bb9ca/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8091 -t public bin/dev-router.php"
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-3fc9a10171d3/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8091 -t public bin/dev-router.php"
],
"port": 8091
},
@@ -79,6 +79,26 @@
],
"port": 8094
},
{
"name": "ebsolar",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebsolar.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebsolar-media'; $env:SITE_ORIGIN='http://127.0.0.1:8096'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8096 -t public bin/dev-router.php"
],
"port": 8096
},
{
"name": "ebsolar-prod",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebsolar.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebsolar-media'; $env:SITE_ORIGIN='http://127.0.0.1:8097'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8097 -t public bin/dev-router.php"
],
"port": 8097
},
{
"name": "immobilien-deeg-prod",
"runtimeExecutable": "powershell",
+4
View File
@@ -377,6 +377,10 @@ Der Nachbau von maler-drews.com (Malermeister Axel Drews, Gernsbach) liegt in `v
Der Onepager „DEEG – Immobilien & Hausverwaltung in Freudenstadt“ (Deeg Hausverwaltung und Immobilien GmbH) liegt in `var/immobilien-deeg.db` mit Medien in `var/immobilien-deeg-media`; die Startkonfiguration `immobilien-deeg` in `.claude/launch.json` nutzt Port 8094, `immobilien-deeg-prod` denselben Bestand im Produktionsmodus auf Port 8095 zum Messen. `scripts/seed-immobilien-deeg.php <Bilderordner>` baut Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Menü, Einstellungen und zwei Formulare auf – `kontakt` und `bewertung` (Bewertungsanfrage mit Auswahl Wohnung, Haus, Mehrfamilienhaus, Grundstück); `sh scripts/reset-immobilien-deeg.sh [Bilderordner]` (Standard `var/immobilien-deeg-assets`: Logo und zwei Architekturfotos aus dem gelieferten Paket) wirft die Installation weg und baut sie neu auf. Die Vorlage nutzt Arial, es gibt keine Schrift zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts.
### Sechste lokale Referenzwebsite: EB Solar
Der Redesign-Entwurf von ebsolar.de (Elektro Bernhard Regenerative Energie GmbH, Baden-Baden) liegt in `var/ebsolar.db` mit Medien in `var/ebsolar-media`; die Startkonfiguration `ebsolar` in `.claude/launch.json` nutzt Port 8096, `ebsolar-prod` denselben Bestand im Produktionsmodus auf Port 8097 zum Messen. `scripts/seed-ebsolar.php <Bilderordner>` baut Gestaltung, Kopf- und Fußbereich, den Kontaktbereich als wiederverwendbare Sektion, Startseite, Projektübersicht, Solarcheck, Impressum, Datenschutz, 404, acht Projektbeiträge mit Galerie und Bildbetrachter, Menü, Einstellungen und zwei Formulare auf – `kontakt` und `solarcheck` (acht Schritte wie das Original auf ebsolar.de, die fünf Auswahlfragen als Bildkarten – gezeichnet und gerastert von `node scripts/build-ebsolar-options.mjs`); `sh scripts/reset-ebsolar.sh [Bilderordner]` (Standard `var/ebsolar-assets`: Logo, Favicon, Startbild, sechs Herstellerlogos, 31 Projektfotos und `projects.json` aus dem gelieferten Paket) wirft die Installation weg und baut sie neu auf. Die Vorlage nutzt Arial, es gibt keine Schrift zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts.
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`.
+1 -1
View File
@@ -1 +1 @@
1.21.2
1.21.6
+41
View File
@@ -1,5 +1,46 @@
# Projektstatus
## Favicon mit Zuschnitt, Pflichtseiten per Suche, neueste zuerst (18.09.2026)
**Anlass:** Umut: „Was ein Dropdown? nicht dein ernst? Sofort fixen über Mediathek auswählbar machen zurecht schneidbar machen und auch uploadbar dort machen!“ – dazu „Genauso Adresse Datenschutz und Impressum über search felder auswählbar machen“ und „Beiträge und Seiten sortieren absteigend also neueste zuerst“.
- **Symbol der Website** hat einen eigenen Abschnitt in den Einstellungen statt einer Auswahlliste am Ende der Unternehmensdaten. Der Knopf öffnet die Mediathek (dort auch Upload), danach folgt ein Zuschnitt: Quadrat ziehen, Größe an der Ecke, per Regler oder mit +/−, Pfeiltasten zum Verschieben, Vorschau in 180 und 32 px. Gespeichert wird `faviconCrop` als `links,oben,kante` in Anteilen (links/oben von Breite/Höhe, Kante von der kürzeren Seite), `renderFavicon()` schneidet daraus die PNGs. `faviconVersion` hängt am Verweis; ohne Zuschnitt bleibt es bei den ersten acht Zeichen der Bildkennung, bestehende Verweise ändern sich also nicht.
- `openMedia()` kennt `needsAlt: false`: Für das Symbol wird kein Alternativtext verlangt. Die Stile des Mediathek-Dialogs stehen jetzt in `media-dialog.css` statt in `editor.css`, damit der Dialog auch außerhalb des Editors funktioniert.
- **Datenschutz und Impressum** werden über die Inhaltssuche gewählt wie die 404-Seite. Die Suche kann jetzt statt der Kennung den Pfad speichern (`valueKey: 'path'` bzw. `data-value="path"`), gespeichert wird also weiter die Adresse.
- **Seiten- und Beitragsliste** der Verwaltung sortieren nach Anlagedatum absteigend. Die Einrückung der Unterseiten ist dort entfallen – ohne Baumreihenfolge hätte sie eine falsche Elternseite angedeutet.
## Sechste Installation: der Redesign-Entwurf „EB Solar“ (16.09.2026)
**Anlass:** Umut: „bitte redesign aufsetzen ebsolar.de achte auf die richtlinien wie immer machs wie die anderen tagespflege, elektro-baernhard co“ – dazu das Paket `ebsolar-redesign.zip` (statische Website: Startseite, Projektübersicht, acht Projektseiten, eigene SVG-Symbole, Kontaktformular per `mailto`).
**Neu.** Neben Kant-IT, Elektro Bernhard, Tagespflege Moldovan, Maler Drews und DEEG steht jetzt **EB Solar** (Elektro Bernhard Regenerative Energie GmbH, Baden-Baden): Datenbank `var/ebsolar.db`, Medien `var/ebsolar-media`, Bildquellen `var/ebsolar-assets` (Logo, Favicon, Startbild, sechs Herstellerlogos, 31 Projektfotos, `projects.json`), Startkonfigurationen `ebsolar` (Port 8096, dev) und `ebsolar-prod` (Port 8097, `APP_ENV=prod` zum Messen). `scripts/seed-ebsolar.php` baut Gestaltung, Kopf- und Fußbereich, den Kontaktbereich als **wiederverwendbare Sektion**, Startseite, Projektübersicht, Solarcheck, Impressum, Datenschutz, 404, acht Projektbeiträge, Menü und zwei Formulare; `sh scripts/reset-ebsolar.sh` wirft weg, migriert und baut neu. Schrift ist Arial (System) wie in der Vorlage, es gibt nichts zu holen.
**Aus der Vorlage übernommen:** Farben (`#b52c2c` Rot, `#252b30` Tinte, `#f5f6f6` helle Fläche, `#e6e8e8` Linie), Schriftgrößen, Abstände, Wortlaut und die Reihenfolge der Abschnitte – Hero mit Bildunterschrift über dem Foto, die vier Zusicherungen zwischen zwei Linien, drei Schritte, „Gutes Handwerk. Neue Energie.“ auf heller Fläche, Projektraster, Herstellerleiste, „Was kann Ihr Dach?“.
**Bewusste Abweichungen.**
- Das Kontaktformular der Vorlage öffnet nur das E-Mail-Programm des Besuchers. Hier ist es das serverseitige Formular `kontakt`; die Anfrage liegt in der Verwaltung und geht an pv@elektro-bernhard.de.
- Der **Solarcheck** ist im Paket nur ein Verweis auf ebsolar.de. Hier ist er eine eigene Seite mit dem Formular `solarcheck`: dieselben acht Schritte und dieselben Fragen wie im Original (abgerufen 16.09.2026), nur der Schieberegler für den Jahresverbrauch ist ein Zahlenfeld – ein Schieberegler ist im Formularbaustein nicht vorgesehen.
- Die acht Projektseiten sind **Beiträge** der Kategorie „Projekte“ mit Galerie und Bildbetrachter; `/projekte` ist eine Beitragsliste. Die Projektart („Photovoltaik“, „Terrassenmontage · SolarEdge“) steht als **Schlagwort** am Beitrag und nicht als Kategorie: „vorheriges/nächstes Projekt“ folgt der ersten Kategorie, und die muss bei allen acht dieselbe sein, sonst führt die Navigation an Rheinstetten vorbei. Die Beitragsliste zeigt nur Kategorien, deshalb tragen die Karten anders als in der Vorlage keine Artzeile.
- Impressum und Datenschutz sind eigene Seiten (Angaben vom Impressum der laufenden Website); was nur Elektro Bernhard beantworten kann, steht als „NOCH ZU PRÜFEN“ im Text.
- Kein Cookie-Banner: es wird nichts von Dritten geladen.
**Bildkarten im Solarcheck (17.09.2026).** Umut: „du kannst so schöne svg's erstellen kannst du bitte noch schöne icons für den solarcheck machen statt checkboxe schöne bilder?“ Die fünf Auswahlfragen (Personen, Dachform, Dachfläche, Eindeckung, Speicher) zeigen jetzt Bildkarten statt Optionsfelder – 20 Symbole in Tinte und Rot auf heller Fläche, im Stil der übrigen Symbole der Website. `scripts/build-ebsolar-options.mjs` zeichnet sie als SVG und rastert sie mit Chromium auf 800×600 (4:3 wie die Karte), weil die Mediathek kein SVG nimmt; SVG und PNG liegen in `var/ebsolar-assets/options/`, das Aufbauskript hängt die PNGs per `images` an die Felder. Lighthouse `/solarcheck` danach: Leistung 98/98, Barrierefreiheit, Best Practices, SEO 100.
**Im Kern behoben: Pfeile waren Schriftzeichen.** Der Button haengte ein `↗` an, die Beitragsnavigation ein `←`/`→` – Buchstaben also, die je nach Schrift anders aussehen und mit der Schriftstaerke nicht mitwachsen. Der Symbolsatz (`templates/blocks/icon.html.twig`, `DocumentValidator::ICONS`) kennt jetzt drei Pfeile: `arrow-right`, `arrow-left`, `arrow-up-right`, gezeichnet wie die uebrigen 17 Symbole. Button und Beitragsnavigation setzen sie als eingebettetes SVG in denselben `<span aria-hidden>` wie bisher – `& span{display:none}` nimmt den Pfeil also weiterhin weg, wo eine Vorlage keinen will (DEEG). Die Groesse haengt an der Schrift des Knopfs (`.button-arrow>svg{width:1.15em}`), nicht an einer festen Zahl. Auf EB Solar tragen auch die Textlinks ihren Pfeil als Symbolbaustein neben dem Text; er zeigt auf dieselbe Adresse und ist als Schmuck von Vorlesesoftware und Tastatur ausgenommen.
**Im Kern behoben: zweimal derselbe Selektor in eigenem CSS loeschte die erste Regel.** `CustomCode::parse()` legte die Deklarationen je Selektor in einem Array ab – `$rules[$suffix] = …`. Standen `& .post-card{border:0}` und `& .post-card{transform:none}` untereinander, gewann die zweite, und `border:0` verschwand ohne Meldung; im Projektraster von EB Solar kam dadurch der 1-px-Rahmen des Kerns zurueck. In CSS ist zweimal derselbe Selektor eine Kaskade, kein Widerruf: die Deklarationen werden jetzt in der Reihenfolge zusammengefuegt, in der sie standen. Dasselbe galt fuer `&{…}` neben den Eigenschaften des Elements selbst – auch das ging vorher verloren.
**Im Kern behoben: das vorgezogene Bild wurde nicht vorab geholt.** Für ein Hintergrundbild des ersten Abschnitts schreibt `page.html.twig` längst ein `<link rel="preload" as="image">` (`heroBackground`). Für ein gewöhnliches `<img>` über der Falz fehlte das Gegenstück – der Browser fand es erst, wenn das Stylesheet geparst war, und diese Rundreise ging auf die Startseite von EB Solar (deren Startbild ein Bild und kein Hintergrund ist) direkt in die LCP-Zeit. Neu liefert `PageController::heroImage()` Quelle, Variantensatz und Größenangabe genau so, wie `blocks/block.html.twig` sie an das `img` schreibt (sonst lädt der Browser zweimal), und die Vorlage schreibt daraus die Vorabladung – aber nur, wenn nicht ohnehin schon ein Hintergrundbild vorgeholt wird.
**Und eine Regel fürs Aufbauen:** eine Einblendung auf dem größten Element über der Falz kostet fast eine Sekunde LCP (gemessen: 916 ms „element render delay“, mobil 92 statt 99). Das Startbild blendet deshalb nicht ein; Kleinzeile, Überschrift, Text und Knöpfe daneben schon.
**Geprüft:** `npm run test:links` gegen Port 8096: 17 Adressen in der Sitemap, 48 Seiten, 127 Adressen, kein toter Verweis. `npm run test:browser` (`PHP_BINARY=C:/php84/php.exe`): sechs Seiten-/Viewport-Prüfungen mit axe, Tastatur und ohne JavaScript bestanden, **keine redaktionellen Befunde**; Ladebudget der Startseite 14 Anfragen, 17 Abfragen. Lighthouse 13.4.1 (`scripts/audit.mjs`, je drei Läufe mobil und Desktop, Mediane) gegen den Produktionsmodus auf Port 8097 für `/`, `/projekte`, `/solarcheck`, `/michelbach`, `/rheinstetten`, `/impressum`, `/datenschutz`: **Barrierefreiheit, Best Practices und SEO überall 100**, Leistung 99–100, agentisches Browsing überall 100 außer `/solarcheck` mobil (98). Startseite LCP 2,1 s mobil / 0,8 s Desktop, CLS 0, TBT 0 ms; Berichte in `reports/lighthouse-ebsolar/`. Bildbetrachter der Galerie geöffnet und geblättert („1 von 4“), Beitragsnavigation stimmt mit der Reihenfolge der Vorlage überein (Michelbach ← Rheinstetten, → Au am Rhein), 404 und Solarcheck im Browser angesehen, Desktop und 375 px. PHP-Tests **581 grün** (PHP 8.4), php-cs-fixer und Twig-Lint ohne Befund.
**Stolperstein beim Messen:** Im Produktionsmodus liegen die uebersetzten Twig-Vorlagen in `var/cache/prod`. Nach einer Aenderung an `blocks/icon.html.twig` warf jede Seite mit Pfeil dort **500** („Key ‚arrow-right‘ … does not exist“), waehrend der Entwicklungsserver auf 8096 laengst richtig lieferte. `APP_ENV=prod … bin/console cache:clear` raeumt das auf; wer im Produktionsmodus misst, muss das nach jeder Vorlagenaenderung tun.
**Bekannt:** `/solarcheck` mobil hat CLS 0,08 (agentisches Browsing 98). Das Formularskript blendet nach dem Laden alle Schritte bis auf den ersten aus – ohne Skript stehen sie untereinander, mit Skript springt die Seite einmal. Der Wert liegt unter dem Ziel von 0,1, aber die Bewegung ist sichtbar; sie gehört in den Kern (Schritte serverseitig zusammenfalten), nicht ins Aufbauskript.
## Startseite nach der Installation: eine Baustellenseite statt einer Anleitung (16.09.2026)
**Anlass:** Umut: „Kannst du bitte für die Erstseite bzw. nach der Installation eine schöne Erstseite erstellen – Under Construction oder so, ohne Details mit ‚melde dich an‘ usw. Am besten auch noch Logo von KantSites: ‚Hier entsteht eine neue Webpräsenz mit KantSites‘.“
+18 -2
View File
@@ -751,9 +751,25 @@ details.card > summary:hover { background: #fafbf8 }
.install-details > summary { padding: 14px 0; font-size: 13px; font-weight: 600; cursor: pointer }
.install-details[open] > summary { border-bottom: 1px solid var(--line); margin-bottom: 6px }
.favicon-field{display:flex;align-items:center;gap:12px}
.favicon-field select{flex:1}
.favicon-field{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px}
.favicon-current{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted)}
.favicon-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
.favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
.favicon-editor{display:grid;grid-template-columns:minmax(0,1fr) 220px;gap:20px;align-items:start;margin-top:14px}
.favicon-editor[hidden]{display:none}
.favicon-stage{position:relative;justify-self:start;max-width:100%;line-height:0;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px;user-select:none;touch-action:none}
.favicon-stage img{display:block;max-width:100%;max-height:360px;width:auto;height:auto}
.favicon-frame{position:absolute;box-sizing:border-box;border:2px solid #fff;outline:1px solid rgba(0,0,0,.6);box-shadow:0 0 0 9999px rgba(20,30,25,.55);cursor:move}
.favicon-stage{overflow:hidden}
.favicon-frame:focus-visible{outline:3px solid var(--accent);outline-offset:0}
.favicon-handle{position:absolute;right:-7px;bottom:-7px;width:14px;height:14px;background:#fff;border:1px solid rgba(0,0,0,.6);border-radius:3px;cursor:nwse-resize}
.favicon-side input[type=range]{width:100%}
.favicon-samples{display:flex;align-items:flex-end;gap:16px;margin:12px 0}
.favicon-samples figure{margin:0;text-align:center;font-size:11px;color:var(--muted)}
.favicon-samples canvas{display:block;margin:0 auto 6px;border-radius:8px;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/12px 12px;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
.favicon-sample-large{width:90px;height:90px}
.favicon-sample-small{width:32px;height:32px;border-radius:4px!important}
@media(max-width:700px){.favicon-editor{grid-template-columns:1fr}}
.color-field { display: flex; gap: 8px; align-items: center }
.color-field input[type="text"], .color-field > input:not([type="color"]) { flex: 1; min-width: 0 }
-25
View File
@@ -11,31 +11,6 @@ dialog::backdrop{background:#172e2966}
dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.visually-hidden{position:absolute!important;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
/* ---- media library dialog (unchanged) ---------------------------------------------------- */
.media-launch{margin-top:14px;white-space:normal}
.media-dialog{width:calc(100% - 32px);max-width:1100px;max-height:calc(100dvh - 32px);overflow:auto}
.media-heading{display:flex;justify-content:space-between;gap:20px;align-items:center}
.media-heading h2{margin:0}
.media-heading .eyebrow{margin-bottom:8px}
.media-tools{display:grid;grid-template-columns:1fr 1fr;gap:24px}
.media-tools label{margin-top:10px}
.media-layout{display:grid;grid-template-columns:minmax(0,1fr) 270px;gap:24px}
.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:12px;align-content:start;max-height:430px;overflow:auto;padding:4px}
.media-card{border:2px solid var(--line);border-radius:9px;padding:7px;background:white;color:var(--ink);display:flex;flex-direction:column;gap:7px;text-align:left;min-width:0}
.media-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
.media-card img{width:100%;height:115px;object-fit:cover;border-radius:5px}
.media-card strong{font-size:12px;overflow-wrap:anywhere}
.media-card span{font-size:11px;color:var(--muted)}
.media-details{padding-left:22px;border-left:1px solid var(--line)}
.media-details button,.media-details a{margin-top:12px;display:block}
.media-details input{font-size:13px}
.media-footer{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:15px}
.media-footer label{margin:0;font-size:12px}
#media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
#media-status{font-size:12px;margin-block:15px}
.media-dialog> .panel-hint{margin-top:20px}
.media-details>p{font-size:12px}
@media(max-width:767px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
/* ---- shell ------------------------------------------------------------------------------- */
html:has(>.editor-body){overflow:hidden;height:100%}
+137
View File
@@ -0,0 +1,137 @@
import { openMedia } from './media.js';
/*
* The site icon in the settings: an image from the media library (uploads happen right there in
* the dialog), then a square cut-out chosen by dragging. The crop is stored as fractions -
* left and top of the width and height, the edge of the shorter side - so it holds for every
* variant of the image, and the server cuts the PNG files from it when the form is saved.
*/
const field = document.querySelector('[data-favicon-field]');
if (field) setup(field);
function setup(field) {
const idInput = field.querySelector('[name="faviconMediaId"]');
const cropInput = field.querySelector('[name="faviconCrop"]');
const choose = field.querySelector('[data-favicon-choose]');
const remove = field.querySelector('[data-favicon-remove]');
const current = field.querySelector('.favicon-current');
const base = document.querySelector('meta[name="admin-base"]').content;
const editor = document.createElement('div'); editor.className = 'favicon-editor'; editor.hidden = true;
editor.innerHTML = `<div class="favicon-stage"><img alt="" draggable="false"><div class="favicon-frame" tabindex="0" role="application" aria-label="Ausschnitt für das Symbol. Mit den Pfeiltasten verschieben, mit Umschalt in großen Schritten, mit Plus und Minus die Größe ändern."><span class="favicon-handle" aria-hidden="true"></span></div></div>
<div class="favicon-side">
<label for="favicon-size">Größe des Ausschnitts</label>
<input id="favicon-size" type="range" min="2" max="100" step="1" value="100">
<p class="field-help">Quadrat im Bild verschieben, an der Ecke oder mit dem Regler vergrößern und verkleinern.</p>
<div class="favicon-samples" aria-label="Vorschau">
<figure><canvas width="180" height="180" class="favicon-sample-large"></canvas><figcaption>Startbildschirm</figcaption></figure>
<figure><canvas width="32" height="32" class="favicon-sample-small"></canvas><figcaption>Browser-Reiter</figcaption></figure>
</div>
<button type="button" class="text-button" data-favicon-center>Mittig ausrichten</button>
</div>`;
field.after(editor);
const stage = editor.querySelector('.favicon-stage');
const image = stage.querySelector('img');
const frame = stage.querySelector('.favicon-frame');
const size = editor.querySelector('#favicon-size');
const large = editor.querySelector('.favicon-sample-large');
const small = editor.querySelector('.favicon-sample-small');
let crop = { x: 0, y: 0, s: 1 };
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const edge = () => crop.s * Math.min(image.naturalWidth, image.naturalHeight);
/** Keep the square inside the picture, then show it and write it into the form. */
const apply = () => {
const w = image.naturalWidth, h = image.naturalHeight; if (!w || !h) return;
crop.s = clamp(crop.s, 0.02, 1);
const e = edge();
crop.x = clamp(crop.x, 0, (w - e) / w); crop.y = clamp(crop.y, 0, (h - e) / h);
Object.assign(frame.style, { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${e / w * 100}%`, height: `${e / h * 100}%` });
size.value = Math.round(crop.s * 100);
cropInput.value = [crop.x, crop.y, crop.s].map(value => String(Math.round(value * 1e6) / 1e6)).join(',');
for (const canvas of [large, small]) {
const context = canvas.getContext('2d'); context.clearRect(0, 0, canvas.width, canvas.height);
context.imageSmoothingQuality = 'high';
context.drawImage(image, crop.x * w, crop.y * h, e, e, 0, 0, canvas.width, canvas.height);
}
};
const center = () => {
const w = image.naturalWidth, h = image.naturalHeight, e = edge();
crop.x = (w - e) / 2 / w; crop.y = (h - e) / 2 / h; apply();
};
/** Resize around the middle of the square, so the slider and the keys do not drift it away. */
const resize = s => {
const w = image.naturalWidth, h = image.naturalHeight, before = edge();
crop.s = clamp(s, 0.02, 1); const after = edge();
crop.x += (before - after) / 2 / w; crop.y += (before - after) / 2 / h; apply();
};
const load = (src, initial) => new Promise((resolve, reject) => {
image.onload = () => { editor.hidden = false; crop = initial || { x: 0, y: 0, s: 1 }; if (initial) apply(); else center(); resolve(); };
image.onerror = () => reject(new Error('Das Bild konnte nicht geladen werden.'));
image.src = src;
});
const largest = item => item.variants.at(-1).src;
const showCurrent = label => {
current.replaceChildren(); current.append(document.createTextNode(label));
choose.textContent = idInput.value ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen';
remove.hidden = !idInput.value;
};
choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: async items => {
if (!items.length) return;
idInput.value = items[0].id; cropInput.value = '';
showCurrent(`Neues Symbol aus „${items[0].title}“ – wird beim Speichern übernommen.`);
try { await load(largest(items[0])); frame.focus({ preventScroll: true }); editor.scrollIntoView({ block: 'nearest' }); }
catch (error) { showCurrent(error.message); }
} }));
remove.addEventListener('click', () => {
idInput.value = ''; cropInput.value = ''; editor.hidden = true; image.removeAttribute('src');
showCurrent('Mitgeliefertes Zeichen – wird beim Speichern übernommen.'); choose.focus();
});
editor.querySelector('[data-favicon-center]').addEventListener('click', center);
size.addEventListener('input', () => resize(size.value / 100));
// Drag to move, drag the corner to resize. Pointer events cover mouse, pen and touch alike.
let drag = null;
frame.addEventListener('pointerdown', event => {
event.preventDefault(); frame.setPointerCapture(event.pointerId); frame.focus({ preventScroll: true });
drag = { mode: event.target.classList.contains('favicon-handle') ? 'resize' : 'move', startX: event.clientX, startY: event.clientY, crop: { ...crop }, rect: stage.getBoundingClientRect() };
});
frame.addEventListener('pointermove', event => {
if (!drag) return;
const w = image.naturalWidth, h = image.naturalHeight, { rect } = drag;
const dx = (event.clientX - drag.startX) / rect.width, dy = (event.clientY - drag.startY) / rect.height;
if (drag.mode === 'move') { crop.x = drag.crop.x + dx; crop.y = drag.crop.y + dy; }
else {
// The corner follows the larger of both movements, measured in image pixels.
const grow = Math.max(dx * w, dy * h), shorter = Math.min(w, h);
const room = Math.min(w - drag.crop.x * w, h - drag.crop.y * h);
crop.x = drag.crop.x; crop.y = drag.crop.y;
crop.s = clamp(drag.crop.s * shorter + grow, 0.02 * shorter, room) / shorter;
}
apply();
});
const stop = () => { drag = null; };
frame.addEventListener('pointerup', stop); frame.addEventListener('pointercancel', stop);
frame.addEventListener('keydown', event => {
const step = event.shiftKey ? 0.1 : 0.01;
const moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
if (moves[event.key]) { event.preventDefault(); crop.x += moves[event.key][0]; crop.y += moves[event.key][1]; apply(); }
else if (['+', '='].includes(event.key)) { event.preventDefault(); resize(crop.s + step); }
else if (event.key === '-') { event.preventDefault(); resize(crop.s - step); }
});
// An icon that is already set opens with its picture and its cut-out.
if (/^[a-f0-9]{32}$/.test(idInput.value)) {
fetch(`${base}/api/media/${idInput.value}`, { credentials: 'same-origin' })
.then(response => { if (!response.ok || response.redirected) throw new Error(); return response.json(); })
.then(({ item }) => {
const parts = cropInput.value.split(',').map(Number);
return load(largest(item), parts.length === 3 && parts.every(Number.isFinite) ? { x: parts[0], y: parts[1], s: parts[2] } : null);
})
.catch(() => {});
}
}
+30
View File
@@ -0,0 +1,30 @@
/* The media library dialog. Loaded by the editor and by every screen that picks an image from
the library (the site icon in the settings), so it does not rely on editor.css. */
.media-dialog{padding:28px;background:var(--paper);border:1px solid var(--line);border-radius:12px;color:var(--ink);box-shadow:0 20px 80px #1b2a2433}
.media-dialog::backdrop{background:#172e2966}
.media-dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
.media-dialog .panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin:0 0 12px}
.media-launch{margin-top:14px;white-space:normal}
.media-dialog{width:calc(100% - 32px);max-width:1100px;max-height:calc(100dvh - 32px);overflow:auto}
.media-heading{display:flex;justify-content:space-between;gap:20px;align-items:center}
.media-heading h2{margin:0}
.media-heading .eyebrow{margin-bottom:8px}
.media-tools{display:grid;grid-template-columns:1fr 1fr;gap:24px}
.media-tools label{margin-top:10px}
.media-layout{display:grid;grid-template-columns:minmax(0,1fr) 270px;gap:24px}
.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:12px;align-content:start;max-height:430px;overflow:auto;padding:4px}
.media-card{border:2px solid var(--line);border-radius:9px;padding:7px;background:white;color:var(--ink);display:flex;flex-direction:column;gap:7px;text-align:left;min-width:0}
.media-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
.media-card img{width:100%;height:115px;object-fit:cover;border-radius:5px}
.media-card strong{font-size:12px;overflow-wrap:anywhere}
.media-card span{font-size:11px;color:var(--muted)}
.media-details{padding-left:22px;border-left:1px solid var(--line)}
.media-details button,.media-details a{margin-top:12px;display:block}
.media-details input{font-size:13px}
.media-footer{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:15px}
.media-footer label{margin:0;font-size:12px}
#media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
#media-status{font-size:12px;margin-block:15px}
.media-dialog> .panel-hint{margin-top:20px}
.media-details>p{font-size:12px}
@media(max-width:767px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
+4 -3
View File
@@ -40,7 +40,8 @@ export function createMediaPreview(initial = {}) {
return { element: wrap, update };
}
export function openMedia({ multiple = false, onChoose } = {}) {
/** needsAlt: false for images that never appear as content, such as the site icon. */
export function openMedia({ multiple = false, onChoose, needsAlt = true } = {}) {
const opener = document.activeElement;
const dialog = element('dialog', '', 'media-dialog'); dialog.id = 'media-dialog';
dialog.setAttribute('aria-labelledby', 'media-title');
@@ -128,11 +129,11 @@ export function openMedia({ multiple = false, onChoose } = {}) {
}
$('#media-upload').value = ''; lock(false); render(); details(); error(failures.join(' · '));
});
$('#media-insert').hidden = !onChoose; $('#decorative-choice').hidden = !onChoose;
$('#media-insert').hidden = !onChoose; $('#decorative-choice').hidden = !onChoose || !needsAlt;
$('#media-insert').addEventListener('click', () => {
const chosen = [...selected].map(id => items.find(item => item.id === id)).filter(item => item && !item.archived);
const decorative = $('#media-decorative').checked;
if (!decorative && chosen.some(item => !item.alt.trim())) { error('Bitte für die gewählten Bilder einen Alternativtext speichern oder die dekorative Verwendung wählen.'); return; }
if (needsAlt && !decorative && chosen.some(item => !item.alt.trim())) { error('Bitte für die gewählten Bilder einen Alternativtext speichern oder die dekorative Verwendung wählen.'); return; }
onChoose(chosen.map(item => ({ ...item, decorative }))); dialog.close();
});
dialog.showModal(); lock(true);
+2 -1
View File
@@ -11,7 +11,8 @@ export function setupPicker(picker, onSelect = () => {}) {
input.removeAttribute('aria-activedescendant'); current = -1;
};
const select = item => {
value.value = item?.id ?? '';
// Most fields store the page id; a setting that keeps an address asks for the path instead.
value.value = item?.[picker.dataset.value || 'id'] ?? '';
input.value = item ? `${item.title} (/${item.path === 'home' ? '' : item.path})` : '';
input.setCustomValidity(''); status.textContent = item ? 'Ausgewählt.' : '';
close(); value.dispatchEvent(new Event('change', { bubbles: true })); onSelect(item);
+12
View File
@@ -44,6 +44,11 @@ p{hyphens:auto}
/* Blocks introduced with schema v6. No JavaScript: the accordion is details/summary. */
.block-icon{flex-shrink:0;display:block}
/* Der Pfeil am Button und in der Beitragsnavigation ist dasselbe Symbol wie ueberall sonst -
frueher stand dort ein Schriftzeichen, das je nach Schrift anders aussah. Er waechst mit der
Schrift des Knopfs, statt eine eigene Groesse zu haben. */
.button-arrow,.nav-arrow{display:inline-flex;align-items:center}
.button-arrow>svg,.nav-arrow>svg{width:1.15em;height:1.15em}
.block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px}
.block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px}
/* Gestapelt brauchen Karten den Abstand; nebeneinander in einer Zeile macht er die zweite bis
@@ -492,6 +497,13 @@ body.consent-open{padding-bottom:320px}
@media (min-width:1025px){.visibility-tablet,.visibility-mobile,.visibility-hide-desktop{display:none!important}}
@media (min-width:768px) and (max-width:1024px){.visibility-desktop,.visibility-mobile,.visibility-hide-tablet{display:none!important}}
@media (max-width:767px){.visibility-desktop,.visibility-tablet,.visibility-hide-mobile{display:none!important}}
/* Das Sprungziel des Knopfs „nach oben“, ein leerer <span> am Anfang von <body>.
WebKit springt nicht zu einem Ziel, dessen Box inline ist und keine Flaeche hat - der Tipp
kam an, #seitenanfang stand in der Adresse, und die Seite blieb stehen. Auf dem iPhone,
und ebenso in Safari auf dem Mac; Chromium ist toleranter, deshalb fiel es am Schreibtisch
nie auf. Ein leerer Block ohne Hoehe reicht WebKit, es ist die Inline-Box, die stoert.
Nachgestellt in Playwright-WebKit mit iPhone-Profil: inline bleibt bei 1500, block springt. */
#seitenanfang{display:block}
/* Der Knopf „nach oben". Ein Anker, kein Skript: er springt an den Anfang der Seite, und das
kann ein Browser seit jeher allein. Eingeblendet wird er beim Scrollen über eine Scroll-
Zeitachse - dieselbe Technik wie bei den Einblendungen. Wo es die nicht gibt (heute noch
+114
View File
@@ -0,0 +1,114 @@
/*
* Zeichnet die Bildkarten des Solarchecks von EB Solar und rastert sie zu PNG.
*
* Die Formularbausteine zeigen eine Auswahl als Bildkarten, sobald eine Option ein Bild aus der
* Mediathek hat - und die Mediathek nimmt JPEG, PNG und WebP, kein SVG. Deshalb entstehen die
* Symbole hier als SVG (leicht zu ändern, alles in einer Datei) und werden mit Chromium auf
* 800 × 600 gerastert, dem Seitenverhältnis 4:3 der Karte. Stil wie die übrigen Symbole der
* Website: dünne Linie in Tinte, runde Enden, ein roter Akzent, helle Fläche dahinter.
*
* node scripts/build-ebsolar-options.mjs → var/ebsolar-assets/options/*.png und *.svg
*/
import { chromium } from 'playwright';
import { mkdir, writeFile } from 'node:fs/promises';
const ziel = process.argv[2] || 'var/ebsolar-assets/options';
const TINTE = '#252b30', ROT = '#b52c2c', HELL = '#f5f6f6', PAPIER = '#ffffff', GRAU = '#c9ced0';
/** Ein Bild: 800 × 600, helle Fläche, Inhalt in der Mitte. */
const bild = (inhalt) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600" width="800" height="600">
<rect width="800" height="600" fill="${HELL}"/>
<g fill="none" stroke="${TINTE}" stroke-width="14" stroke-linecap="round" stroke-linejoin="round">${inhalt}</g>
</svg>`;
/** Eine Person: Kopf und Schultern. */
const person = (x, y, s = 1, farbe = TINTE) => `
<circle cx="${x}" cy="${y - 120 * s}" r="${46 * s}" stroke="${farbe}"/>
<path d="M${x - 88 * s} ${y + 70 * s}v-${30 * s}a${88 * s} ${88 * s} 0 0 1 ${176 * s} 0v${30 * s}" stroke="${farbe}"/>`;
/** Ein Haus: Wände plus Dach nach Angabe; das Dach ist der Akzent. */
const haus = (dach) => `
<path d="M190 520V300M610 520V300M150 520h500"/>
<rect x="360" y="410" width="80" height="110" rx="6"/>
${dach}`;
/** Ein Dach als schräge Fläche mit Modulen im Raster. */
const module = (spalten, zeilen) => {
const x0 = 160, y0 = 150, b = 480, h = 300, scher = 90;
const zellen = [];
for (let z = 0; z < zeilen; z++) {
for (let s = 0; s < spalten; s++) {
const bx = b / spalten, bh = h / zeilen;
const x = x0 + s * bx + (z * bh) * (scher / h), y = y0 + z * bh;
zellen.push(`<path d="M${x + 8} ${y + 8}h${bx - 16}l${(bh - 16) * scher / h} ${bh - 16}h-${bx - 16}z" fill="${ROT}" fill-opacity=".14" stroke="${ROT}" stroke-width="8"/>`);
}
}
return `<path d="M${x0} ${y0}h${b}l${scher} ${h}h-${b}z"/>${zellen.join('')}`;
};
const fragezeichen = (x, y, s = 1) => `<path d="M${x - 40 * s} ${y - 50 * s}a${40 * s} ${40 * s} 0 1 1 ${58 * s} ${36 * s}c-${14 * s} ${9 * s}-${18 * s} ${18 * s}-${18 * s} ${34 * s}" stroke="${ROT}"/><circle cx="${x}" cy="${y + 65 * s}" r="9" fill="${ROT}" stroke="none"/>`;
const batterie = (inhalt, farbe = TINTE) => `
<rect x="190" y="200" width="380" height="200" rx="26" stroke="${farbe}"/>
<path d="M570 260h30a20 20 0 0 1 20 20v40a20 20 0 0 1-20 20h-30" stroke="${farbe}"/>
${inhalt}`;
const bilder = {
// Wie viele Personen leben im Haushalt?
'personen-1': person(400, 320, 1.3),
'personen-2': person(300, 320, 1.15) + person(500, 320, 1.15),
'personen-3-4': person(210, 330) + person(400, 330) + person(590, 330),
'personen-4-plus': person(160, 330, .85) + person(320, 330, .85) + person(480, 330, .85) + person(640, 330, .85)
+ `<path d="M715 140v70M680 175h70" stroke="${ROT}"/>`,
// Welche Form hat Ihr Dach?
'dach-flach': haus(`<path d="M150 300h500" stroke="${ROT}"/><path d="M200 300v-40h400v40" stroke="${ROT}"/>`),
'dach-sattel': haus(`<path d="M140 320 400 120l260 200" stroke="${ROT}"/>`),
'dach-pult': haus(`<path d="M150 330 650 190" stroke="${ROT}"/><path d="M610 300v-95" />`),
'dach-versetzt': `
<path d="M120 520h560M160 520V370M440 520V270M660 520V330"/>
<rect x="260" y="420" width="70" height="100" rx="6"/>
<path d="M120 380 290 250l150 120" stroke="${ROT}"/><path d="M440 290 560 180l130 140" stroke="${ROT}"/>`,
'dach-sonstiges': haus(`<path d="M140 320 400 120l260 200" stroke="${GRAU}"/>`) + fragezeichen(400, 250, 1.1),
// Wie groß ist Ihre Dachfläche?
'flaeche-50': module(2, 1),
'flaeche-100': module(3, 2),
'flaeche-200': module(5, 3),
'flaeche-200-plus': module(7, 4),
'flaeche-unsicher': `<path d="M160 150h480l90 300H160z" stroke="${GRAU}"/>` + fragezeichen(400, 300, 1.4),
// Aus welchem Material besteht die Dacheindeckung?
'eindeckung-ziegel': [0, 1, 2, 3].map(z => [0, 1, 2, 3, 4].map(s => {
if (z % 2 === 1 && s === 4) return '';
const x = 150 + s * 100 + (z % 2) * 50, y = 170 + z * 80;
return `<path d="M${x} ${y}a50 50 0 0 0 100 0" stroke="${z === 2 && s === 2 ? ROT : TINTE}"/>`;
}).join('')).join(''),
'eindeckung-bitumen': `
<path d="M130 440h540v50H130z"/>
<circle cx="250" cy="360" r="70"/><circle cx="250" cy="360" r="26"/>
<path d="M320 400h340" stroke="${ROT}" stroke-width="22"/>
<path d="M250 430h410" stroke="${ROT}" stroke-width="22" stroke-opacity=".35"/>`,
'eindeckung-blech': `
<path d="M120 340h50l30-70h60l30 70h60l30-70h60l30 70h60l30-70h60l30 70h30"/>
<path d="M120 420h50l30-70h60l30 70h60l30-70h60l30 70h60l30-70h60l30 70h30" stroke="${GRAU}"/>
<path d="M120 340v80M680 340v80" stroke="${GRAU}"/>
<path d="M350 340l30-70h60l30 70" stroke="${ROT}"/>`,
'eindeckung-anderes': `<rect x="200" y="150" width="400" height="300" rx="20" stroke="${GRAU}"/>` + fragezeichen(400, 300, 1.4),
// Wünschen Sie einen Stromspeicher?
'speicher-ja': batterie(`<path d="M420 230 350 310h60l-30 70 70-80h-60z" fill="${ROT}" stroke="${ROT}" stroke-width="10"/>`),
'speicher-nein': batterie('', GRAU) + `<path d="M190 470 610 130" stroke="${ROT}"/>`,
};
await mkdir(ziel, { recursive: true });
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 800, height: 600 }, deviceScaleFactor: 1 });
for (const [name, inhalt] of Object.entries(bilder)) {
const svg = bild(inhalt);
await writeFile(`${ziel}/${name}.svg`, svg);
await page.setContent(`<!doctype html><body style="margin:0;background:${PAPIER}">${svg}</body>`);
await page.locator('svg').screenshot({ path: `${ziel}/${name}.png`, omitBackground: false });
console.log('gezeichnet:', name);
}
await browser.close();
+4 -1
View File
@@ -1014,7 +1014,10 @@ try {
// looked at from a visitor's browser: the banner has to appear, and the embed must not load.
await page.goto(origin + '/admin/settings');
await page.getByLabel('Einwilligungshinweis anzeigen', { exact: true }).check();
await page.getByLabel('Adresse der Datenschutzseite', { exact: true }).fill('grundlagen');
// The privacy page is chosen by search, the way the 404 page is: type, then take the hit.
await page.getByLabel('Datenschutzseite', { exact: true }).fill('grundlagen');
await page.locator('#privacyPath-results [role=option]').filter({ hasText: '/grundlagen' }).first().click();
assert.equal(await page.locator('.content-picker:has(#privacyPath) .picker-value').inputValue(), 'grundlagen');
await page.getByRole('button', { name: 'Einstellungen speichern', exact: true }).click();
await page.locator('.notice.ok').waitFor();
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# Wirft die EB-Solar-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
set -e
root="C:/Django Project/KantSites"
php="${PHP_BINARY:-C:/php84/php.exe}"
export DATABASE_URL="sqlite:///$root/var/ebsolar.db"
export MEDIA_DIRECTORY="$root/var/ebsolar-media"
rm -f "$root/var/ebsolar.db"
rm -rf "$root/var/ebsolar-media"
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
assets="${1:-$root/var/ebsolar-assets}"
# Die Vorlage nutzt Arial; es gibt keine Schrift zu holen.
"$php" -d memory_limit=512M scripts/seed-ebsolar.php "$assets"
File diff suppressed because it is too large. Load diff
+19 -2
View File
@@ -193,17 +193,34 @@ final class CustomCode
if (1 !== preg_match('/^[a-zA-Z0-9 .:>+~_\[\]="\'-]*$/D', $suffix)) {
throw new InvalidDocument(sprintf('„&%s“ ist als verschachtelter Selektor nicht erlaubt.', $suffix));
}
$rules[$suffix] = $this->declarations($match[2][0]);
// Zusammenfuegen statt ersetzen: zweimal derselbe Selektor ist in CSS eine Kaskade,
// nicht ein Widerruf. Vorher gewann die letzte Regel, und alles, was nur in der
// ersten stand, verschwand ohne Meldung - gefunden am Projektraster von EB Solar,
// wo `& .post-card{border:0}` und `& .post-card{transform:none}` nebeneinander
// standen und der Rahmen des Kerns wieder auftauchte.
$rules[$suffix] = $this->verbinden($rules[$suffix] ?? '', $this->declarations($match[2][0]));
$rest = substr_replace($rest, '', $match[0][1], strlen($match[0][0]));
}
if (str_contains($rest, '{') || str_contains($rest, '}')) {
throw new InvalidDocument('Eigenes CSS besteht aus Eigenschaften für dieses Element und aus verschachtelten Regeln, die mit & beginnen. Andere Selektoren sind nicht vorgesehen.');
}
$rules[''] = $this->declarations($rest);
// Die Eigenschaften des Elements selbst stehen ueblicherweise vor den verschachtelten
// Regeln; eine Regel `&{…}` schreibt sie aber in denselben Schluessel.
$rules[''] = $this->verbinden($this->declarations($rest), $rules['']);
return $rules;
}
/** Zwei Deklarationslisten desselben Selektors, in der Reihenfolge, in der sie standen. */
private function verbinden(string $erste, string $zweite): string
{
if ('' === $erste || '' === $zweite) {
return '' === $erste ? $zweite : $erste;
}
return $erste.';'.$zweite;
}
/** @throws InvalidDocument */
private function declarations(string $block): string
{
+1 -1
View File
@@ -16,7 +16,7 @@ final class DocumentValidator
/** Blocks introduced with schema v6 for the sections a marketing page actually needs. */
public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'];
/** Icons ship as inline SVG from this fixed list; no icon font and no external request. */
public const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home'];
public const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home', 'arrow-right', 'arrow-left', 'arrow-up-right'];
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
/** Schema v7: tabs, the one pattern that needs a script to become a real widget. */
public const V7_TYPES = ['tabs', 'tabitem'];
+19 -6
View File
@@ -222,11 +222,16 @@ final class MediaLibrary
* Erzeugt aus einem Bild der Mediathek die Symboldateien der Website.
*
* Ein Favicon ist quadratisch, winzig und wird von den Browsern bis heute am zuverlässigsten
* als PNG gelesen - deshalb hier PNG statt der WebP-Varianten und ein mittiger quadratischer
* als PNG gelesen - deshalb hier PNG statt der WebP-Varianten und ein quadratischer
* Ausschnitt statt eines gestauchten Bildes. Erzeugt wird einmal beim Speichern der
* Einstellung, nicht bei jedem Aufruf.
*
* $crop ist der im Zuschnitt gewählte Ausschnitt als [links, oben, Kante]: links und oben als
* Anteil von Breite und Höhe, die Kante als Anteil der kürzeren Seite. Ohne Angabe die Mitte.
*
* @param array{0: float, 1: float, 2: float}|null $crop
*/
public function renderFavicon(string $mediaId): void
public function renderFavicon(string $mediaId, ?array $crop = null): void
{
// Ueber die Varianten, die dieses Bild tatsaechlich hat, nicht ueber die vier Regelbreiten:
// ein 192 px breites Zeichen hat genau eine Variante mit 192 - und die Suche nach 480
@@ -244,10 +249,18 @@ final class MediaLibrary
throw new \RuntimeException('Das Bild für das Symbol konnte nicht gelesen werden.');
}
try {
// Mittiger quadratischer Ausschnitt: ein breites Logo würde sonst zur Briefmarke.
$kante = min(imagesx($source), imagesy($source));
$links = (int) ((imagesx($source) - $kante) / 2);
$oben = (int) ((imagesy($source) - $kante) / 2);
// Quadratischer Ausschnitt: ein breites Logo würde sonst zur Briefmarke. Die Anteile
// gelten für jede Variante gleich; gerechnet wird in der größten, die es gibt.
$breite = imagesx($source);
$hoehe = imagesy($source);
$kante = min($breite, $hoehe);
$links = (int) (($breite - $kante) / 2);
$oben = (int) (($hoehe - $kante) / 2);
if (null !== $crop) {
$kante = max(1, min($kante, (int) round($crop[2] * min($breite, $hoehe))));
$links = max(0, min($breite - $kante, (int) round($crop[0] * $breite)));
$oben = max(0, min($hoehe - $kante, (int) round($crop[1] * $hoehe)));
}
foreach (self::FAVICON_SIZES as $size) {
$target = imagecreatetruecolor($size, $size);
try {
+3 -2
View File
@@ -459,8 +459,9 @@ final class PageRepository
$total = (int) (clone $qb)->select('COUNT(*)')->executeQuery()->fetchOne();
$pages = max(1, (int) ceil($total / $limit));
$page = max(1, min($pages, $page));
$items = $qb->select('p.*', 'r.document')->orderBy('post' === $type ? 'p.created_at' : 'p.path', 'post' === $type ? 'DESC' : 'ASC')
->addOrderBy('p.id')->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->executeQuery()->fetchAllAssociative();
$items = $qb->select('p.*', 'r.document')->orderBy('p.created_at', 'DESC')
// Neueste zuerst, bei Seiten wie bei Beiträgen (Umut: „absteigend also neueste zuerst“).
->addOrderBy('p.id', 'DESC')->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->executeQuery()->fetchAllAssociative();
return ['items' => $items, 'total' => $total, 'page' => $page, 'pages' => $pages, 'limit' => $limit];
}
+16 -1
View File
@@ -50,6 +50,10 @@ final class SiteSettings
// Das Symbol der Website im Browser-Reiter: ein Bild aus der Mediathek, aus dem beim
// Speichern quadratische PNG-Dateien entstehen. Leer heißt: das mitgelieferte Zeichen.
'faviconMediaId' => '',
// Der Zuschnitt dazu als "links,oben,kante" in Anteilen, leer heißt: die Mitte. Die Version
// hängt am Verweis, damit ein neuer Ausschnitt im Browser ankommt.
'faviconCrop' => '',
'faviconVersion' => '',
// Der Knopf „nach oben". Aus, weil eine kurze Seite ihn nicht braucht; alles daran ist
// einstellbar, weil er auf jeder Seite in der Ecke klebt und deshalb zur Gestaltung
// passen muss. Eingeblendet wird er beim Scrollen - ohne eine Zeile JavaScript.
@@ -229,6 +233,14 @@ final class SiteSettings
$values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
$values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false);
$favicon = trim((string) ($input['faviconMediaId'] ?? ''));
$crop = '' === $favicon ? '' : trim((string) ($input['faviconCrop'] ?? ''));
$anteile = null;
if ('' !== $crop) {
$anteile = array_map('floatval', explode(',', $crop));
if (1 !== preg_match('/^[01](?:\.\d{1,6})?,[01](?:\.\d{1,6})?,[01](?:\.\d{1,6})?$/D', $crop) || max($anteile) > 1 || $anteile[2] < 0.02) {
throw new InvalidPage('Der Ausschnitt für das Symbol ist ungültig. Bitte das Bild neu zuschneiden.');
}
}
if ('' !== $favicon) {
if (1 !== preg_match('/^[a-f0-9]{32}$/D', $favicon) || null === $this->media->find($favicon)) {
throw new InvalidPage('Das gewählte Symbolbild gibt es nicht (mehr).');
@@ -237,12 +249,15 @@ final class SiteSettings
// die Einstellung gar nicht erst gespeichert - ein Verweis auf ein fehlendes Symbol
// wäre schlimmer als das alte Symbol.
try {
$this->media->renderFavicon($favicon);
$this->media->renderFavicon($favicon, $anteile);
} catch (\RuntimeException $error) {
throw new InvalidPage('Aus diesem Bild konnte kein Symbol erzeugt werden: '.$error->getMessage());
}
}
$values['faviconMediaId'] = $favicon;
$values['faviconCrop'] = $crop;
// Ohne Zuschnitt die Kennung wie bisher, damit bestehende Verweise gleich bleiben.
$values['faviconVersion'] = '' === $favicon ? '' : ('' === $crop ? substr($favicon, 0, 8) : substr(md5($favicon.'|'.$crop), 0, 8));
foreach (['turnstileSiteKey' => 100, 'turnstileSecret' => 100] as $key => $max) {
$wert = trim((string) ($input[$key] ?? ''));
if ('' !== $wert && 1 !== preg_match('/^[A-Za-z0-9_:.-]{8,'.$max.'}$/D', $wert)) {
+58 -2
View File
@@ -31,6 +31,9 @@ final class PageController extends AbstractController
{
}
/** Dieselbe Groessenangabe, die `blocks/block.html.twig` einem Bild ohne feste Breite gibt. */
private const IMAGE_SIZES = '(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px';
// A segment is a slug; up to four of them form the public path. The leading lookahead keeps
// the CMS routes (/admin, /media, /assets) out of the catch-all, which matches before them.
private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}';
@@ -209,10 +212,14 @@ final class PageController extends AbstractController
// Das grosse Bild ueber der Falz - es soll zuerst geladen werden. Gesucht wird nur
// im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche
// Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort.
'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
'eagerImage' => $eagerImage = $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]),
// Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz
// ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts.
'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']),
'heroBackground' => $heroBackground = $this->heroBackground($areas['header'] ?? null, $page['document']),
// Ist es doch ein gewoehnliches Bild, wird auch das vorab geholt - sonst findet der
// Browser es erst, wenn das Stylesheet steht, und diese Rundreise geht direkt in die
// LCP-Zeit. Nur eines von beiden: zwei grosse Bilder vorab kosten mehr, als sie bringen.
'heroImage' => [] === $heroBackground ? $this->heroImage($page['document'], $eagerImage) : null,
'pageStyles' => $styles,
'localFonts' => $localFonts,
'styleBundle' => $this->bundle->url([] !== $localFonts),
@@ -772,6 +779,55 @@ final class PageController extends AbstractController
return [];
}
/**
* Das vorgezogene Bild als Vorabladung im Kopf der Seite.
*
* Die Angaben sind dieselben, die `blocks/block.html.twig` an das `img` schreibt - Quelle,
* Satz der Varianten und die Groessenangabe. Weicht eines davon ab, waehlt der Browser fuer
* die Vorabladung eine andere Variante als fuer das Bild und laedt zweimal.
*
* @return array{src: string, srcset: string, sizes: string}|null
*/
private function heroImage(array $document, ?string $eagerImage): ?array
{
if (null === $eagerImage) {
return null;
}
$block = $this->blockById($document['blocks'] ?? [], $eagerImage);
$props = $block['props'] ?? [];
if (null === $block || 'image' !== ($block['type'] ?? null) || '' === (string) ($props['src'] ?? '')) {
return null;
}
$varianten = [];
foreach (is_array($props['sources'] ?? null) ? $props['sources'] : [] as $quelle) {
if (is_array($quelle) && isset($quelle['src'], $quelle['width'])) {
$varianten[] = $quelle['src'].' '.$quelle['width'].'w';
}
}
$feste = (string) ($block['style']['desktop']['maxWidth'] ?? '');
return [
'src' => (string) $props['src'],
'srcset' => implode(', ', $varianten),
'sizes' => str_ends_with($feste, 'px') ? $feste : self::IMAGE_SIZES,
];
}
private function blockById(array $blocks, string $id): ?array
{
foreach ($blocks as $block) {
if ($id === ($block['id'] ?? null)) {
return $block;
}
$inside = $this->blockById($block['children'] ?? [], $id);
if (null !== $inside) {
return $inside;
}
}
return null;
}
private function firstImage(array ...$documents): ?string
{
foreach ($documents as $document) {
+17 -5
View File
@@ -4,7 +4,6 @@ namespace App\Controller;
use App\Content\Consent;
use App\Content\DemoContent;
use App\Content\MediaLibrary;
use App\Content\NotFoundPage;
use App\Content\PageRepository;
use App\Content\RedirectRepository;
@@ -20,7 +19,7 @@ use Symfony\Component\Routing\Attribute\Route;
final class SettingsController extends AbstractController
{
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private MediaLibrary $media, private Theme $theme, private NotFoundPage $notFoundPage)
public function __construct(private SiteSettings $settings, private RedirectRepository $redirects, private PageRepository $pages, private Theme $theme, private NotFoundPage $notFoundPage)
{
}
@@ -31,8 +30,22 @@ final class SettingsController extends AbstractController
// Veroeffentlichers, die Unternehmensdaten samt Einwilligung und Spamschutz die des
// Administrators - der Bildschirm blendet den Teil aus, den die Rolle nicht aendern darf.
$this->denyAccessUnlessGranted('ROLE_PUBLISHER');
$settings = $this->settings->load();
$choosable = [];
// Datenschutz und Impressum werden per Suche gewählt; das Feld zeigt Titel und Adresse der
// gespeicherten Seite. Eine Adresse ohne Seite bleibt sichtbar, statt still zu verschwinden.
$legal = ['privacyPath' => null, 'imprintPath' => null];
foreach (array_keys($legal) as $key) {
if ('' !== $settings[$key]) {
$legal[$key] = ['path' => $settings[$key], 'title' => $settings[$key]];
}
}
foreach ($this->pages->listForEditor() as $page) {
foreach ($legal as $key => $selection) {
if (null !== $selection && $selection['path'] === $page['path']) {
$legal[$key]['title'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR)['title'] ?? $page['path'];
}
}
// Only a published page can serve as the root; a draft would answer every visit with 404.
if ('page' === $page['type'] && null !== $page['published_revision_id']) {
$document = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR);
@@ -41,15 +54,14 @@ final class SettingsController extends AbstractController
}
return $this->render('admin/settings.html.twig', [
'settings' => $this->settings->load(),
'settings' => $settings,
'legalSelection' => $legal,
'notFoundSelection' => $this->notFoundPage->selected(),
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
'choosableHome' => $choosable,
'consentGroups' => Consent::GROUPS,
'missingLegal' => $this->settings->missingLegalPages(),
'consentGroupHelp' => Consent::GROUP_HELP,
// Für das Symbol der Website: die Mediathek zur Auswahl, die neuesten zuerst.
'images' => $this->media->page(1, false, 96)['items'],
// Fuer die Farbfelder: dieselbe Palette, die auch der Editor anbietet.
'palette' => $this->theme->load()['colors'],
'socialNetworks' => SocialProfiles::NETWORKS,
+2 -2
View File
@@ -1,11 +1,11 @@
{% set selected = selected|default(null) %}
<div class="content-picker" data-source="{{ path('admin_content_choices') }}" data-type="{{ type|default('') }}" data-parents="{{ parents|default(false) ? '1' : '0' }}">
<div class="content-picker" data-source="{{ path('admin_content_choices') }}" data-type="{{ type|default('') }}" data-parents="{{ parents|default(false) ? '1' : '0' }}"{% if valueKey|default('id') != 'id' %} data-value="{{ valueKey }}"{% endif %}>
<label for="{{ id }}">{{ label|default('Seite suchen') }}</label>
<div class="picker-input">
<input id="{{ id }}" type="search" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="{{ id }}-results" placeholder="Titel oder Adresse suchen …" value="{{ selected ? selected.title ~ ' (/' ~ selected.path ~ ')' : '' }}">
<button type="button" class="subtle picker-clear" aria-label="{{ label|default('Seite') }}: Auswahl entfernen">✕</button>
</div>
<input type="hidden" class="picker-value" name="{{ name }}" value="{{ selected ? selected.id : '' }}">
<input type="hidden" class="picker-value" name="{{ name }}" value="{{ selected ? attribute(selected, valueKey|default('id')) : '' }}">
<div id="{{ id }}-results" class="picker-results" role="listbox" aria-label="Suchergebnisse" hidden></div>
<p class="picker-status field-help" role="status"></p>
</div>
+3 -3
View File
@@ -3,9 +3,9 @@
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten oben'} %}
<div class="rows">
{% for page in pages %}
{# Indentation by class, not by style attribute: the administration forbids inline styles, so
an attribute here would simply be dropped and the tree would look flat. #}
<article class="row row-stack depth-{{ min(page.depth, 3) }}">
{# Newest first, not as a tree: indenting subpages here would suggest a parent that is not
the row above. The address in the row shows where a page sits. #}
<article class="row row-stack">
<div class="row-line">
<span class="row-icon" aria-hidden="true">{{ page.type == 'post' ? '✎' : '▤' }}</span>
<div class="row-main">
+1 -1
View File
@@ -4,7 +4,7 @@
{% set listRoute = area ? 'admin_partials' : (page.type == 'post' ? 'admin_posts' : 'admin_pages') %}
{% block title %}{{ subject.title }} bearbeiten{% endblock %}
{% block body_class %}editor-body{% endblock %}
{% block styles %}<link rel="stylesheet" href="/assets/editor.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/button-controls.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
{% block styles %}<link rel="stylesheet" href="/assets/editor.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/buttons.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/button-controls.css?v={{ kant_version() }}"><link rel="stylesheet" href="/assets/color-control.css?v={{ kant_version() }}">{% endblock %}
{% block scripts %}<script type="module" src="/assets/editor.js?v={{ kant_version() }}"></script>{% endblock %}
{% block body %}
{# The API base decides what is edited. An area is shared, so it has no draft and no
+16 -13
View File
@@ -3,7 +3,8 @@
{% block title %}Einstellungen{% endblock %}
{% block heading %}Unternehmen und Adressen{% endblock %}
{% block lead %}Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.{% endblock %}
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script>{% endblock %}
{% 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>{% endblock %}
{% block content %}
{% if missingLegal is not empty %}
@@ -129,15 +130,19 @@
<p class="field-help">Braucht mindestens Straße und Ort. Sinnvoll bei einem Standort mit Kundenverkehr.</p>
<label class="check-label" for="llmsTxt"><input id="llmsTxt" name="llmsTxt" type="checkbox" value="1"{{ settings.llmsTxt ? ' checked' : '' }}> <code>llms.txt</code> ausliefern</label>
<p class="field-help">Eine maschinenlesbare Liste der veröffentlichten, indexierbaren Seiten. Optional. Kein Rankingfaktor und keine Voraussetzung für irgendeine Suchfunktion; ohne Aktivierung antwortet die Adresse mit 404.</p>
<label for="faviconMediaId">Symbol der Website</label>
<div class="favicon-field">
{% if settings.faviconMediaId %}<img class="favicon-preview" src="/favicon.png?v={{ settings.faviconMediaId|slice(0, 8) }}" alt="" width="32" height="32">{% endif %}
<select id="faviconMediaId" name="faviconMediaId">
<option value="">Mitgeliefertes Zeichen</option>
{% for image in images %}<option value="{{ image.id }}"{{ settings.faviconMediaId == image.id ? ' selected' : '' }}>{{ image.title }}</option>{% endfor %}
</select>
<div class="section-head"><h2 id="favicon-title">Symbol der Website</h2><span class="muted">Favicon im Browser-Reiter und auf dem Startbildschirm</span></div>
<div class="favicon-field" data-favicon-field aria-labelledby="favicon-title" role="group">
<input type="hidden" name="faviconMediaId" value="{{ settings.faviconMediaId }}">
<input type="hidden" name="faviconCrop" value="{{ settings.faviconCrop }}">
<div class="favicon-current">
{% if settings.faviconMediaId %}<img class="favicon-preview" src="/favicon.png?v={{ settings.faviconVersion ?: settings.faviconMediaId|slice(0, 8) }}" alt="" width="32" height="32"> <span>Eigenes Symbol</span>{% else %}<img class="favicon-preview" src="/favicon.svg" alt="" width="32" height="32"> <span>Mitgeliefertes Zeichen</span>{% endif %}
</div>
<div class="favicon-actions">
<button type="button" class="secondary" data-favicon-choose>{{ settings.faviconMediaId ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen' }}</button>
<button type="button" class="text-button danger-text" data-favicon-remove{{ settings.faviconMediaId ? '' : ' hidden' }}>Mitgeliefertes Zeichen verwenden</button>
</div>
</div>
<p class="field-help">Das Bild im Browser-Reiter und auf dem Startbildschirm. Aus dem gewählten Bild entsteht beim Speichern ein quadratischer Ausschnitt aus der Mitte in 32 und 180 Pixeln – ein Logo mit viel Rand wird darin klein, ein quadratisches Zeichen funktioniert am besten.</p>
<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>
<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>
@@ -261,13 +266,11 @@
</fieldset>
<div class="fields">
<div>
<label for="privacyPath">Adresse der Datenschutzseite</label>
<input id="privacyPath" name="privacyPath" maxlength="400" value="{{ settings.privacyPath }}" placeholder="datenschutz">
{% include 'admin/content-picker.html.twig' with {id: 'privacyPath', name: 'privacyPath', type: 'page', valueKey: 'path', label: 'Datenschutzseite', selected: legalSelection.privacyPath} only %}
<p class="field-help">Pflicht, sobald der Hinweis an ist, und die Seite muss existieren.</p>
</div>
<div>
<label for="imprintPath">Adresse des Impressums</label>
<input id="imprintPath" name="imprintPath" maxlength="400" value="{{ settings.imprintPath }}" placeholder="impressum">
{% include 'admin/content-picker.html.twig' with {id: 'imprintPath', name: 'imprintPath', type: 'page', valueKey: 'path', label: 'Impressum', selected: legalSelection.imprintPath} only %}
</div>
</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>
+1 -1
View File
@@ -26,7 +26,7 @@
{% endif %}
{% elseif type == 'button' %}
{% for device, settings in layout.button|default({}) %}{% for key, value in settings %}{% set designClasses = designClasses ~ ' b-' ~ device ~ '-' ~ key ~ '-' ~ value %}{% endfor %}{% endfor %}
<a id="{{ block.id }}" class="button cms-button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.text }} <span aria-hidden="true">↗</span></a>
<a id="{{ block.id }}" class="button cms-button{{ designClasses }}" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>{{ props.text }} <span class="button-arrow" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: 'arrow-up-right'} only %}</span></a>
{% elseif type == 'image' %}
{# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit
190px, ein Symbol mit 72px -, dann ist das die Antwort, und der Browser laedt die kleine
+4 -1
View File
@@ -16,6 +16,9 @@
check: 'm4 12.5 5.5 5.5L20 6',
people: 'M9.5 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM2.5 20v-1c0-3.3 3-5 7-5s7 1.7 7 5v1M16.5 5.3a3.2 3.2 0 0 1 0 6.4M18 14.4c2.1.7 3.5 2.1 3.5 4.1V20',
music: 'M10 18V5.5L20 3v12.5M10 9.5 20 7M10 18a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0ZM20 15.5a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0Z',
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20'
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
'arrow-right': 'M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5',
'arrow-left': 'M20 12H4.5M11 5.5 4.5 12 11 18.5',
'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9'
} %}
<svg class="block-icon" viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="{{ paths[name] }}"/></svg>
+2 -2
View File
@@ -4,13 +4,13 @@
<nav class="post-nav" aria-label="Weitere Beiträge">
{% if previous %}
<a class="post-nav-link post-nav-previous" href="{{ previous.href }}">
<span class="post-nav-label"><span aria-hidden="true">←</span> {{ previousLabel|default('Vorheriger Beitrag') }}</span>
<span class="post-nav-label"><span class="nav-arrow" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: 'arrow-left'} only %}</span> {{ previousLabel|default('Vorheriger Beitrag') }}</span>
<span class="post-nav-title">{{ previous.title }}</span>
</a>
{% else %}<span></span>{% endif %}
{% if next %}
<a class="post-nav-link post-nav-next" href="{{ next.href }}">
<span class="post-nav-label">{{ nextLabel|default('Nächster Beitrag') }} <span aria-hidden="true">→</span></span>
<span class="post-nav-label">{{ nextLabel|default('Nächster Beitrag') }} <span class="nav-arrow" aria-hidden="true">{% include 'blocks/icon.html.twig' with {name: 'arrow-right'} only %}</span></span>
<span class="post-nav-title">{{ next.title }}</span>
</a>
{% endif %}
+5 -3
View File
@@ -1,9 +1,11 @@
{# Das Symbol der Website. Eigenes Bild aus den Einstellungen, sonst das mitgelieferte Zeichen.
Die Kennung hängt als Version am Verweis, damit ein Wechsel im Browser auch ankommt. #}
{% set favicon = siteSettings.load().faviconMediaId|default('') %}
{% set site = siteSettings.load() %}
{% set favicon = site.faviconMediaId|default('') %}
{% set version = site.faviconVersion|default('') ?: favicon|slice(0, 8) %}
{% if favicon %}
<link rel="icon" href="/favicon.png?v={{ favicon|slice(0, 8) }}" type="image/png" sizes="32x32">
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v={{ favicon|slice(0, 8) }}">
<link rel="icon" href="/favicon.png?v={{ version }}" type="image/png" sizes="32x32">
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v={{ version }}">
{% else %}
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
{% endif %}
+4
View File
@@ -34,6 +34,10 @@
drei Abfragen trifft zu - und zwar auf dieselbe Variante, die auch die Regel nimmt. #}
{% for bild in heroBackground|default([]) %}<link rel="preload" as="image" href="{{ bild.src }}" media="{{ bild.media }}" fetchpriority="high">
{% endfor %}
{# Und derselbe Dienst fuer das gewoehnliche Bild ueber der Falz: ohne diese Zeile faengt der
Browser es erst an zu holen, wenn er das Stylesheet geparst hat. #}
{% if heroImage|default(null) %}<link rel="preload" as="image" href="{{ heroImage.src }}"{% if heroImage.srcset %} imagesrcset="{{ heroImage.srcset }}" imagesizes="{{ heroImage.sizes }}"{% endif %} fetchpriority="high">
{% endif %}
{% if localFonts|default([]) %}
{# Jeder Schnitt, den das Theme nennt, vorab - und zwar alle. Fehlt einer, laedt er erst,
wenn das Stylesheet geparst ist, und die Ueberschrift springt sichtbar von der
+26 -3
View File
@@ -1398,8 +1398,11 @@ final class CmsTest extends TestCase
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$html = $this->request('/')->getContent();
// Genau ein Bild wird vorgezogen, und zwar das erste im ersten Abschnitt.
self::assertSame(1, substr_count($html, 'fetchpriority="high"'));
// Genau ein Bild wird vorgezogen, und zwar das erste im ersten Abschnitt. Es wird
// zweimal genannt: einmal als Vorabladung im Kopf, einmal als das Bild selbst.
self::assertSame(1, substr_count($html, '<link rel="preload" as="image"'));
self::assertMatchesRegularExpression('#<link rel="preload" as="image" href="[^"]+" imagesrcset="[^"]*480w[^"]*" imagesizes="\(max-width: 650px\) 100vw[^"]*" fetchpriority="high">#', $html);
self::assertSame(2, substr_count($html, 'fetchpriority="high"'));
self::assertMatchesRegularExpression('#<figure id="gross".{0,400}fetchpriority="high"#s', $html);
self::assertMatchesRegularExpression('#<figure id="weiter-unten".{0,400}loading="lazy"#s', $html);
// Ein Block mit fester Breite fragt nach genau dieser Breite.
@@ -1589,6 +1592,19 @@ final class CmsTest extends TestCase
self::assertSame([32, 32], [$size[0], $size[1]], 'Quadratisch, nicht gestaucht.');
self::assertSame(200, $this->request('/apple-touch-icon.png')->getStatusCode());
// Der Zuschnitt aus dem Werkzeug: links oben, ein Viertel der kurzen Seite. Der Verweis
// bekommt eine neue Version, sonst hielte der Browser am alten Ausschnitt fest.
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => $item['id'], 'faviconCrop' => '0,0,0.25']);
self::assertSame('0,0,0.25', $this->site->load()['faviconCrop']);
self::assertStringContainsString('/favicon.png?v='.substr(md5($item['id'].'|0,0,0.25'), 0, 8), $this->request('/')->getContent());
foreach (['2,0,0.5', '0,0,0.01', 'mitte'] as $falsch) {
try {
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => $item['id'], 'faviconCrop' => $falsch]);
self::fail('Ein ungültiger Ausschnitt muss abgelehnt werden: '.$falsch);
} catch (InvalidPage) {
}
}
// Ein winziges quadratisches Zeichen hat keine einzige der vier Regelbreiten - genau so
// sieht ein echtes Favicon aus, und genau daran ist die erste Fassung gescheitert.
$klein = tempnam(sys_get_temp_dir(), 'kant-zeichen-');
@@ -1944,7 +1960,8 @@ final class CmsTest extends TestCase
$template = (string) file_get_contents(__DIR__.'/../templates/blocks/icon.html.twig');
foreach (DocumentValidator::ICONS as $icon) {
self::assertMatchesRegularExpression(
'/\R\s{4}'.preg_quote($icon, '/').": '[^']{4,}',?\R/",
// Ein Name mit Bindestrich braucht in Twig Anfuehrungszeichen um den Schluessel.
'/\R\s{4}\'?'.preg_quote($icon, '/')."'?: '[^']{4,}',?\R/",
$template,
sprintf('Das Symbol „%s“ steht in der Liste, hat aber keine Zeichnung.', $icon),
);
@@ -2983,6 +3000,12 @@ final class CmsTest extends TestCase
yield 'descendant keeps its space' => ['& .block-text { font-size: 20px }', '#b1 .block-text{font-size:20px}'];
yield 'child combinator' => ['& > p { margin: 0 }', '#b1 > p{margin:0}'];
yield 'local image' => ['background: url(/bild.png)', '#b1{background:url(/bild.png)}'];
// Zweimal derselbe Selektor ist eine Kaskade, kein Widerruf: vorher gewann die letzte
// Regel, und was nur in der ersten stand, verschwand ohne Meldung.
yield 'same selector twice' => ['& .card { border: 0 }
& .card { transform: none }', '#b1 .card{border:0;transform:none}'];
yield 'element rule after a nested one' => ['& { color: red }
display: flex', '#b1{display:flex;color:red}'];
}
#[DataProvider('ownCssCases')]