feat(hero): responsive srcset for carousel slides

Phones were downloading the full 1.5 MB / 2 MB hero JPEG. Now the
image macro emits a `srcset` ladder (480/768/1200/1600/2560/3840/5345 w)
with `sizes="100vw"`, so a 480 px-wide phone picks the ~19 KB variant
and 5K screens still get the native resolution.

Also drop `loading="lazy"` on the first slide — it is the LCP element
and should load eager.

Macro changes:
- Add `srcset_widths` (CSV string, since Tera macros only allow scalar
  defaults) and `sizes` parameters.
- Add `lazy=true` parameter; callers can pass `lazy=false` for
  above-the-fold images.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 12:31:47 +02:00
parent b52a79bbab
commit 83726ed040
2 changed files with 39 additions and 17 deletions
+12 -5
View File
@@ -16,11 +16,18 @@
<div class="carousel-inner" role="listbox"> <div class="carousel-inner" role="listbox">
{% for slide in section.extra.slides %} {% for slide in section.extra.slides %}
<div class="item{% if loop.first %} active{% endif %}"> <div class="item{% if loop.first %} active{% endif %}">
{# Serve the source image at full resolution — Bootstrap-3 only {# Bootstrap-3 only constrains hero images via `max-width: 100%`,
constrains via `max-width: 100%`, so on viewports wider than so the natural image width has to cover the widest viewport
the natural image width the slide would otherwise sit short we care about. We hand the browser a `srcset` ladder up to
of the column edge. Live does the same (no resize). #} 5345 px (the source's native width) and let it pick — phones
{{ image::render(src=slide.image, alt=slide.alt) }} grab ~480-800 px, desktops 1600-2560 px, 5K screens the full
file. The first slide is the LCP element, so it loads eager. #}
{{ image::render(
src=slide.image,
alt=slide.alt,
srcset_widths="480,768,1200,1600,2560,3840,5345",
sizes="100vw",
lazy=not loop.first) }}
<div class="carousel-caption"> <div class="carousel-caption">
<p>{{ slide.heading }}</p> <p>{{ slide.heading }}</p>
</div> </div>
+27 -12
View File
@@ -1,22 +1,37 @@
{# Image-rendering macro, used both by templates (directly via import) {# Image-rendering macro, used both by templates (directly via import)
and indirectly by the `img` shortcode (which forwards to it). and indirectly by the `img` shortcode (which forwards to it).
With `width > 0`: resizes via Zola's image pipeline (file size benefit) Modes:
but emits no width/height attributes — the theme CSS sizes the image, - `srcset_widths="480,768,…"` non-empty: emit a responsive `srcset`
matching the live Grav site's pattern. Avoiding the HTML width attribute over those widths. `sizes` should describe the layout (e.g.
sidesteps a stretch artefact triggered by Bootstrap-3's "100vw" for the full-width hero). The `src` fallback uses the
`img { height: auto !important }` interacting with `.float-right` largest width. CSV form because Tera macros only allow scalar
padding. defaults, not arrays.
- `width > 0`: single resized variant via Zola's image pipeline.
- `width = 0` (default): raw <img> straight from /static/images/.
With `width = 0` (default): emits the raw <img> straight from In all modes we omit the HTML width/height attributes — the theme CSS
/static/images/ — same shape, no resize. #} sizes the image, matching the live Grav site's pattern. Avoiding the
HTML width attribute sidesteps a stretch artefact triggered by
Bootstrap-3's `img { height: auto !important }` interacting with
`.float-right` padding.
{% macro render(src, width=0, alt="", class="") %} `lazy=false` switches off `loading="lazy"`. Use it for above-the-fold
images (LCP candidates) like the first hero slide. #}
{% macro render(src, width=0, alt="", class="", srcset_widths="", sizes="", lazy=true) %}
{%- set width = width | int -%} {%- set width = width | int -%}
{%- if width > 0 -%} {%- set loading_attr = "" -%}
{%- if lazy -%}{%- set loading_attr = ' loading="lazy"' -%}{%- endif -%}
{%- if srcset_widths -%}
{%- set widths = srcset_widths | split(pat=",") -%}
{%- set largest = widths | last | trim | int -%}
{%- set fallback = resize_image(path="static/images/" ~ src, width=largest, op="fit_width", quality=82) -%}
<img src="{{ fallback.url | safe }}" srcset="{%- for w_str in widths -%}{%- set w = w_str | trim | int -%}{%- set r = resize_image(path="static/images/" ~ src, width=w, op="fit_width", quality=82) -%}{{ r.url | safe }} {{ w }}w{%- if not loop.last -%}, {%- endif -%}{%- endfor -%}" sizes="{{ sizes }}" alt="{{ alt }}"{% if class %} class="{{ class }}"{% endif %}{{ loading_attr | safe }}>
{%- elif width > 0 -%}
{%- set resized = resize_image(path="static/images/" ~ src, width=width, op="fit_width", quality=85) -%} {%- set resized = resize_image(path="static/images/" ~ src, width=width, op="fit_width", quality=85) -%}
<img src="{{ resized.url | safe }}" alt="{{ alt }}"{% if class %} class="{{ class }}"{% endif %} loading="lazy"> <img src="{{ resized.url | safe }}" alt="{{ alt }}"{% if class %} class="{{ class }}"{% endif %}{{ loading_attr | safe }}>
{%- else -%} {%- else -%}
<img src="/images/{{ src | safe }}" alt="{{ alt }}"{% if class %} class="{{ class }}"{% endif %} loading="lazy"> <img src="/images/{{ src | safe }}" alt="{{ alt }}"{% if class %} class="{{ class }}"{% endif %}{{ loading_attr | safe }}>
{%- endif -%} {%- endif -%}
{% endmacro render %} {% endmacro render %}