Banners/cards used cropResize (fit-inside), so a portrait fallback source was handed back as a narrow sliver that object-fit:cover then upscaled into a blur (reported on us-canada-mex-2024). Switch to cropZoom (crop-to-fill) so the derivative is a real w×h cover strip. Emit the 2x srcset descriptor only when the source is genuinely >=2w wide (cover.width >= 2w), else 1x-only — no upscaling, no odd intermediate widths. Imported pixelfed photos cap at ~1440px wide, so auto-picked covers are usually 1x-only (see backlog: full-res reimport). Also drop the no-photos-demo test fixture — it surfaced as stray demo content in the trip list; AE4 (no cover + no images -> no banner) is a trivial else-branch of the shared macro's cover guard, covered by construction alongside the R7/AE3 fallback tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDS6t8wcpbwKvvrxykVQ5K
58 lines
2.9 KiB
Twig
58 lines
2.9 KiB
Twig
{#
|
||
Shared trip cover: one source of truth for cover resolution + retina
|
||
rendering, used by both the trip-list card (trips.html.twig) and the
|
||
trip-page banner strip (trip-feed-col.html.twig) so the two surfaces
|
||
cannot drift (KTD2).
|
||
|
||
Resolution (R7 / R11):
|
||
1. author-selected header.cover_image, when it resolves to page media;
|
||
2. else the first published journal entry's first image;
|
||
3. else nothing.
|
||
A set-but-missing cover_image (deleted/moved file) is `is not defined` in
|
||
page media, so it falls through to the auto-pick rather than rendering a
|
||
broken image (R11). Resolution matches against `media.images` (not all
|
||
media), so a non-image selection — e.g. a `.gpx` from the trip page's own
|
||
media, which the picker used to offer — also falls through instead of
|
||
routing a non-image Medium into cropResize.
|
||
|
||
Twig macros can only emit strings — they cannot return a Medium object —
|
||
so resolution and rendering live together in one macro: it emits the
|
||
wrapper + retina <img> when a cover exists, and nothing at all when none
|
||
does (which gives R9/AE4 a clean text-only header for free).
|
||
|
||
Rendering (R6 / R14): cropZoom crops-to-fill (cover), so the 1x derivative is
|
||
an actual w×h strip matching the banner/card aspect — NOT cropResize, which
|
||
fits-inside and would hand a portrait source back as a narrow sliver that the
|
||
wrapper's object-fit:cover then upscales into a blur. `alt` is the trip title.
|
||
|
||
Retina, all-or-nothing: a 2x derivative (2w×2h) is added to the `srcset` only
|
||
when the source is genuinely wide enough to supply it without upscaling
|
||
(`cover.width >= 2w`); otherwise the srcset is 1x only — no blurry, re-encoded
|
||
upscale, and no odd intermediate widths. Note most imported phone photos are
|
||
≤1440px wide, so an auto-picked fallback cover is usually 1x-only; the 2x
|
||
kicks in mainly for an explicitly-set wide landscape `cover_image`.
|
||
#}
|
||
{% macro render(trip_page, alt, w, h, wrapper_class, sizes) %}
|
||
{%- set cover = null -%}
|
||
{%- if trip_page.header.cover_image and trip_page.media.images[trip_page.header.cover_image] is defined -%}
|
||
{%- set cover = trip_page.media.images[trip_page.header.cover_image] -%}
|
||
{%- else -%}
|
||
{%- set dailies_page = grav.pages.find(trip_page.route ~ '/dailies') -%}
|
||
{%- if dailies_page -%}
|
||
{%- set first_entry = dailies_page.children.published()|first -%}
|
||
{%- if first_entry and first_entry.media.images|length > 0 -%}
|
||
{%- set cover = first_entry.media.images|first -%}
|
||
{%- endif -%}
|
||
{%- endif -%}
|
||
{%- endif -%}
|
||
{%- if cover -%}
|
||
<div class="{{ wrapper_class }}">
|
||
<img src="{{ cover.cropZoom(w, h).url }}"
|
||
srcset="{{ cover.cropZoom(w, h).url }} {{ w }}w{% if cover.width >= (w * 2) %}, {{ cover.cropZoom(w * 2, h * 2).url }} {{ (w * 2) }}w{% endif %}"
|
||
sizes="{{ sizes|default('100vw') }}"
|
||
alt="{{ alt }}"
|
||
loading="lazy">
|
||
</div>
|
||
{%- endif -%}
|
||
{% endmacro %}
|