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>
This commit is contained in:
kantarciandClaude Fable 5.1 committed 2026-09-17 12:56:57 +02:00
1 parent ab9e6a7ea0
commit d0019c80af
15 files changed
+1472 -12

No files matched your search

+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) {